From b0b3ee0381b7a6773d105f6cffb16be62a045a04 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 08:58:57 -0700 Subject: [PATCH 01/31] More batch tests --- .../v1_11/AbstractDynamoDbClientTest.java | 290 +++++++------- .../v2_2/AbstractAws2ClientCoreTest.java | 349 ++++++++++++----- .../cassandra/v3_0/CassandraClientTest.java | 198 ++++++---- .../common/v4_0/AbstractCassandraTest.java | 214 +++++++---- .../AbstractJdbcInstrumentationTest.java | 360 +++++++++--------- .../v1_0/AbstractR2dbcStatementTest.java | 73 +++- .../redisson/AbstractRedissonClientTest.java | 102 +++-- .../sqlclient/v4_0/VertxSqlClientTest.java | 64 +++- .../sqlclient/v5_0/VertxSqlClientTest.java | 64 +++- 9 files changed, 1093 insertions(+), 621 deletions(-) diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java index be8bbaf96692..652e60d833ab 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java @@ -21,6 +21,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemIncubatingValues.DYNAMODB; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.AWS_DYNAMODB; import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; @@ -39,7 +40,13 @@ import io.opentelemetry.testing.internal.armeria.common.MediaType; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; public abstract class AbstractDynamoDbClientTest extends AbstractBaseAwsClientTest { @@ -82,10 +89,14 @@ void sendRequestWithMockedResponse() throws ReflectiveOperationException { SERVER_PORT); } + // describes the batch cases for the two DynamoDB batch operations (BatchGetItem and + // BatchWriteItem): the request to send and the expected client span. batch attributes + // (db.operation.batch.size, BATCH operation name, db.collection.name) are only emitted under + // stable database semconv; the span and db.operation.name are emitted in both modes @SuppressWarnings("deprecation") // using deprecated semconv - @Test - void batchGetItemWithMultipleItemsUsesStableBatchAttributes() - throws ReflectiveOperationException { + @ParameterizedTest + @MethodSource("batchScenarios") + void batchOperation(BatchScenario scenario) throws ReflectiveOperationException { AmazonDynamoDB client = createClient(); server.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "{}")); @@ -97,147 +108,84 @@ void batchGetItemWithMultipleItemsUsesStableBatchAttributes() maybeStable(DB_SYSTEM), emitStableDatabaseSemconv() ? AWS_DYNAMODB : DYNAMODB), equalTo( maybeStable(DB_OPERATION), - emitStableDatabaseSemconv() ? "BATCH GetItem" : "BatchGetItem"), - equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? Long.valueOf(2) : null), - equalTo(DB_COLLECTION_NAME, emitStableDatabaseSemconv() ? "sometable" : null))); - - Object response = - client.batchGetItem( - new BatchGetItemRequest() - .withRequestItems( - singletonMap( - "sometable", - new KeysAndAttributes() - .withKeys( - asList( - singletonMap("key", new AttributeValue().withS("value")), - singletonMap( - "key", new AttributeValue().withS("anotherValue"))))))); - assertRequestWithMockedResponse( - response, client, "DynamoDBv2", "BatchGetItem", "POST", additionalAttributes); - - assertDurationMetric( - testing(), - "io.opentelemetry.aws-sdk-1.11", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME, - SERVER_ADDRESS, - SERVER_PORT); - } - - @SuppressWarnings("deprecation") // using deprecated semconv - @Test - void batchGetItemWithSingleItemUsesStableItemOperation() throws ReflectiveOperationException { - AmazonDynamoDB client = createClient(); - - server.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "{}")); - - List additionalAttributes = - new ArrayList<>( - asList( + emitStableDatabaseSemconv() ? scenario.stableOperation : scenario.awsOperation), equalTo( - maybeStable(DB_SYSTEM), emitStableDatabaseSemconv() ? AWS_DYNAMODB : DYNAMODB), + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo( - maybeStable(DB_OPERATION), - emitStableDatabaseSemconv() ? "GetItem" : "BatchGetItem"), - equalTo(DB_COLLECTION_NAME, emitStableDatabaseSemconv() ? "sometable" : null))); - - Object response = - client.batchGetItem( - new BatchGetItemRequest() - .withRequestItems( - singletonMap( - "sometable", - new KeysAndAttributes() - .withKeys( - singletonList( - singletonMap("key", new AttributeValue().withS("value"))))))); - assertRequestWithMockedResponse( - response, client, "DynamoDBv2", "BatchGetItem", "POST", additionalAttributes); - - assertDurationMetric( - testing(), - "io.opentelemetry.aws-sdk-1.11", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME, - SERVER_ADDRESS, - SERVER_PORT); - } + DB_COLLECTION_NAME, + emitStableDatabaseSemconv() && scenario.hasCollection ? "sometable" : null))); - @SuppressWarnings("deprecation") // using deprecated semconv - @Test - void batchWriteItemWithMultipleItemsUsesStableBatchAttributes() - throws ReflectiveOperationException { - AmazonDynamoDB client = createClient(); - - server.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "{}")); - - List additionalAttributes = - new ArrayList<>( - asList( - equalTo( - maybeStable(DB_SYSTEM), emitStableDatabaseSemconv() ? AWS_DYNAMODB : DYNAMODB), - equalTo( - maybeStable(DB_OPERATION), - emitStableDatabaseSemconv() ? "BATCH WriteItem" : "BatchWriteItem"), - equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? Long.valueOf(2) : null), - equalTo(DB_COLLECTION_NAME, emitStableDatabaseSemconv() ? "sometable" : null))); - - Object response = - client.batchWriteItem( - new BatchWriteItemRequest() - .withRequestItems( - singletonMap( - "sometable", asList(writeRequest("value"), writeRequest("anotherValue"))))); + Object response = scenario.execute.apply(client); assertRequestWithMockedResponse( - response, client, "DynamoDBv2", "BatchWriteItem", "POST", additionalAttributes); - - assertDurationMetric( - testing(), - "io.opentelemetry.aws-sdk-1.11", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME, - SERVER_ADDRESS, - SERVER_PORT); + response, client, "DynamoDBv2", scenario.awsOperation, "POST", additionalAttributes); } - @SuppressWarnings("deprecation") // using deprecated semconv - @Test - void batchWriteItemWithSingleItemUsesStableItemOperation() throws ReflectiveOperationException { - AmazonDynamoDB client = createClient(); - - server.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "{}")); - - List additionalAttributes = - new ArrayList<>( - asList( - equalTo( - maybeStable(DB_SYSTEM), emitStableDatabaseSemconv() ? AWS_DYNAMODB : DYNAMODB), - equalTo( - maybeStable(DB_OPERATION), - emitStableDatabaseSemconv() ? "WriteItem" : "BatchWriteItem"), - equalTo(DB_COLLECTION_NAME, emitStableDatabaseSemconv() ? "sometable" : null))); + private static Stream batchScenarios() { + return Stream.of( + // an empty batch keeps the raw batch operation name and emits no batch size or + // collection name + BatchScenario.builder("getItemEmpty") + .awsOperation("BatchGetItem") + .execute(client -> client.batchGetItem(getItemRequest(0))) + .stableOperation("BatchGetItem") + .build(), + // a single-item batch is not a batch, so it uses the singular item operation + BatchScenario.builder("getItemSingle") + .awsOperation("BatchGetItem") + .execute(client -> client.batchGetItem(getItemRequest(1))) + .stableOperation("GetItem") + .hasCollection() + .build(), + BatchScenario.builder("getItemTwo") + .awsOperation("BatchGetItem") + .execute(client -> client.batchGetItem(getItemRequest(2))) + .stableOperation("BATCH GetItem") + .batchSize(2) + .hasCollection() + .build(), + BatchScenario.builder("writeItemEmpty") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(writeItemRequest(0))) + .stableOperation("BatchWriteItem") + .build(), + BatchScenario.builder("writeItemSingle") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(writeItemRequest(1))) + .stableOperation("WriteItem") + .hasCollection() + .build(), + BatchScenario.builder("writeItemTwo") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(writeItemRequest(2))) + .stableOperation("BATCH WriteItem") + .batchSize(2) + .hasCollection() + .build()) + .map(Arguments::of); + } - Object response = - client.batchWriteItem( - new BatchWriteItemRequest() - .withRequestItems(singletonMap("sometable", singletonList(writeRequest("value"))))); - assertRequestWithMockedResponse( - response, client, "DynamoDBv2", "BatchWriteItem", "POST", additionalAttributes); + private static BatchGetItemRequest getItemRequest(int count) { + if (count == 0) { + return new BatchGetItemRequest().withRequestItems(emptyMap()); + } + List> keys = new ArrayList<>(); + for (int i = 0; i < count; i++) { + keys.add(singletonMap("key", new AttributeValue().withS("value" + i))); + } + return new BatchGetItemRequest() + .withRequestItems(singletonMap("sometable", new KeysAndAttributes().withKeys(keys))); + } - assertDurationMetric( - testing(), - "io.opentelemetry.aws-sdk-1.11", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME, - SERVER_ADDRESS, - SERVER_PORT); + private static BatchWriteItemRequest writeItemRequest(int count) { + if (count == 0) { + return new BatchWriteItemRequest().withRequestItems(emptyMap()); + } + List writes = new ArrayList<>(); + for (int i = 0; i < count; i++) { + writes.add(writeRequest("value" + i)); + } + return new BatchWriteItemRequest().withRequestItems(singletonMap("sometable", writes)); } private static WriteRequest writeRequest(String value) { @@ -246,6 +194,76 @@ private static WriteRequest writeRequest(String value) { new PutRequest().withItem(singletonMap("key", new AttributeValue().withS(value)))); } + private static final class BatchScenario { + final String name; + final String awsOperation; + final Function execute; + final String stableOperation; + final Long batchSize; + final boolean hasCollection; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.awsOperation = builder.awsOperation; + this.execute = builder.execute; + this.stableOperation = builder.stableOperation; + this.batchSize = builder.batchSize; + this.hasCollection = builder.hasCollection; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static Builder builder(String name) { + return new Builder(name); + } + + static final class Builder { + private final String name; + private String awsOperation; + private Function execute; + private String stableOperation; + private Long batchSize; + private boolean hasCollection; + + Builder(String name) { + this.name = name; + } + + Builder awsOperation(String awsOperation) { + this.awsOperation = awsOperation; + return this; + } + + Builder execute(Function execute) { + this.execute = execute; + return this; + } + + Builder stableOperation(String stableOperation) { + this.stableOperation = stableOperation; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + Builder hasCollection() { + this.hasCollection = true; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } + private AmazonDynamoDB createClient() { AmazonDynamoDBClientBuilder clientBuilder = AmazonDynamoDBClientBuilder.standard(); return configureClient(clientBuilder) diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java index f17fabef54ca..3fc1afaa4b38 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java @@ -566,9 +566,14 @@ void testBatchGetItemWithMultipleTablesOmitsDbCollectionName() { .doesNotContainKey(DB_COLLECTION_NAME)))); } - @Test + // describes the batch cases for the two DynamoDB batch operations (BatchGetItem and + // BatchWriteItem): the request to send, the mocked response and the expected client span. batch + // attributes (db.operation.batch.size, BATCH operation name, db.collection.name) are only emitted + // under stable database semconv; the span and db.operation.name are emitted in both modes + @ParameterizedTest + @MethodSource("batchScenarios") @SuppressWarnings("deprecation") // uses deprecated semconv - void testBatchGetItemWithMultipleItemsUsesStableBatchAttributes() { + void batchOperation(BatchScenario scenario) { DynamoDbClientBuilder builder = DynamoDbClient.builder(); configureSdkClient(builder); DynamoDbClient client = @@ -578,114 +583,260 @@ void testBatchGetItemWithMultipleItemsUsesStableBatchAttributes() { .credentialsProvider(CREDENTIALS_PROVIDER) .build(); server.enqueue( - HttpResponse.of( - HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, getResponseContent("BatchGetItem"))); + HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, scenario.responseContent)); - client.batchGetItem( - b -> - b.requestItems( - ImmutableMap.of( - "sometable", - KeysAndAttributes.builder() - .keys( - asList( - ImmutableMap.of("key", AttributeValue.builder().s("value").build()), - ImmutableMap.of( - "key", AttributeValue.builder().s("anotherValue").build()))) - .build()))); + Object response = scenario.execute.apply(client); + assertThat(response).isNotNull(); getTesting() .waitAndAssertTraces( trace -> trace.hasSpansSatisfyingExactly( - span -> - assertDynamoDbRequest( - span, - "BatchGetItem", - asList( - equalTo( - AWS_DYNAMODB_CONSUMED_CAPACITY, - singletonList( - "{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}")), - equalTo( - DB_OPERATION_BATCH_SIZE, - emitStableDatabaseSemconv() ? Long.valueOf(2) : null)), - "BATCH GetItem"))); - - assertDurationMetric( - getTesting(), - "io.opentelemetry.aws-sdk-2.2", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME); + span -> { + List attributes = + new ArrayList<>( + asList( + equalTo(SERVER_ADDRESS, "127.0.0.1"), + equalTo(SERVER_PORT, server.httpPort()), + equalTo(URL_FULL, server.httpUri() + "/"), + equalTo(HTTP_REQUEST_METHOD, "POST"), + equalTo(HTTP_RESPONSE_STATUS_CODE, 200), + equalTo(RPC_SYSTEM, "aws-api"), + equalTo(RPC_SERVICE, "DynamoDb"), + equalTo(RPC_METHOD, scenario.awsOperation), + equalTo(stringKey("aws.agent"), "java-aws-sdk"), + equalTo(AWS_REQUEST_ID, "UNKNOWN"), + equalTo( + maybeStable(DB_SYSTEM), maybeStableDbSystemName(DYNAMODB)), + equalTo( + maybeStable(DB_OPERATION), + emitStableDatabaseSemconv() + ? scenario.stableOperation + : scenario.awsOperation))); + if (scenario.hasCollection) { + attributes.add( + equalTo(AWS_DYNAMODB_TABLE_NAMES, singletonList("sometable"))); + if (emitStableDatabaseSemconv()) { + attributes.add(equalTo(DB_COLLECTION_NAME, "sometable")); + } + } + attributes.addAll(scenario.extraAttributes()); + span.hasName("DynamoDb." + scenario.awsOperation) + .hasKind(SpanKind.CLIENT) + .hasNoParent() + .hasAttributesSatisfyingExactly(attributes); + })); + + if (scenario.assertMetric) { + assertDurationMetric( + getTesting(), + "io.opentelemetry.aws-sdk-2.2", + DB_SYSTEM_NAME, + DB_OPERATION_NAME, + DB_COLLECTION_NAME); + } } - @Test @SuppressWarnings("deprecation") // uses deprecated semconv - void testBatchWriteItemWithMultipleItemsUsesStableBatchAttributes() { - DynamoDbClientBuilder builder = DynamoDbClient.builder(); - configureSdkClient(builder); - DynamoDbClient client = - builder - .endpointOverride(server.httpUri()) - .region(Region.AP_NORTHEAST_1) - .credentialsProvider(CREDENTIALS_PROVIDER) - .build(); - server.enqueue( - HttpResponse.of( - HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, getResponseContent("BatchWriteItem"))); + private static Stream batchScenarios() { + return Stream.of( + // an empty batch still produces a span, but keeps the raw batch operation name and + // emits no db.operation.batch.size, db.collection.name or table-name attributes + BatchScenario.builder("getItemEmpty") + .awsOperation("BatchGetItem") + .responseContent("{\"ConsumedCapacity\":[]}") + .execute(c -> c.batchGetItem(b -> b.requestItems(ImmutableMap.of()))) + .stableOperation("BatchGetItem") + .build(), + BatchScenario.builder("getItemTwo") + .awsOperation("BatchGetItem") + .responseContent(getResponseContent("BatchGetItem")) + .execute( + c -> + c.batchGetItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + KeysAndAttributes.builder() + .keys( + asList( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build()), + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("anotherValue") + .build()))) + .build())))) + .stableOperation("BATCH GetItem") + .hasCollection() + .batchSize(2) + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .assertMetric() + .build(), + BatchScenario.builder("writeItemTwo") + .awsOperation("BatchWriteItem") + .responseContent(getResponseContent("BatchWriteItem")) + .execute( + c -> + c.batchWriteItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + asList( + WriteRequest.builder() + .putRequest( + PutRequest.builder() + .item( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build())) + .build()) + .build(), + WriteRequest.builder() + .putRequest( + PutRequest.builder() + .item( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("anotherValue") + .build())) + .build()) + .build()))))) + .stableOperation("BATCH WriteItem") + .hasCollection() + .batchSize(2) + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() + .build()) + .map(Arguments::of); + } - client.batchWriteItem( - b -> - b.requestItems( - ImmutableMap.of( - "sometable", - asList( - WriteRequest.builder() - .putRequest( - PutRequest.builder() - .item( - ImmutableMap.of( - "key", AttributeValue.builder().s("value").build())) - .build()) - .build(), - WriteRequest.builder() - .putRequest( - PutRequest.builder() - .item( - ImmutableMap.of( - "key", - AttributeValue.builder().s("anotherValue").build())) - .build()) - .build())))); + private static final class BatchScenario { + final String name; + final String awsOperation; + final String responseContent; + final Function execute; + final String stableOperation; + final boolean hasCollection; + final Long batchSize; + final String consumedCapacity; + final String itemCollectionMetrics; + final boolean assertMetric; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.awsOperation = builder.awsOperation; + this.responseContent = builder.responseContent; + this.execute = builder.execute; + this.stableOperation = builder.stableOperation; + this.hasCollection = builder.hasCollection; + this.batchSize = builder.batchSize; + this.consumedCapacity = builder.consumedCapacity; + this.itemCollectionMetrics = builder.itemCollectionMetrics; + this.assertMetric = builder.assertMetric; + } - getTesting() - .waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> - assertDynamoDbRequest( - span, - "BatchWriteItem", - asList( - equalTo( - AWS_DYNAMODB_CONSUMED_CAPACITY, - singletonList( - "{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}")), - equalTo( - AWS_DYNAMODB_ITEM_COLLECTION_METRICS, - "[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]"), - equalTo( - DB_OPERATION_BATCH_SIZE, - emitStableDatabaseSemconv() ? Long.valueOf(2) : null)), - "BATCH WriteItem"))); - - assertDurationMetric( - getTesting(), - "io.opentelemetry.aws-sdk-2.2", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME); + @SuppressWarnings("deprecation") // uses deprecated semconv + List extraAttributes() { + List attributes = new ArrayList<>(); + if (consumedCapacity != null) { + attributes.add(equalTo(AWS_DYNAMODB_CONSUMED_CAPACITY, singletonList(consumedCapacity))); + } + if (itemCollectionMetrics != null) { + attributes.add(equalTo(AWS_DYNAMODB_ITEM_COLLECTION_METRICS, itemCollectionMetrics)); + } + if (batchSize != null) { + attributes.add( + equalTo(DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? batchSize : null)); + } + return attributes; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static Builder builder(String name) { + return new Builder(name); + } + + static final class Builder { + private final String name; + private String awsOperation; + private String responseContent; + private Function execute; + private String stableOperation; + private boolean hasCollection; + private Long batchSize; + private String consumedCapacity; + private String itemCollectionMetrics; + private boolean assertMetric; + + Builder(String name) { + this.name = name; + } + + Builder awsOperation(String awsOperation) { + this.awsOperation = awsOperation; + return this; + } + + Builder responseContent(String responseContent) { + this.responseContent = responseContent; + return this; + } + + Builder execute(Function execute) { + this.execute = execute; + return this; + } + + Builder stableOperation(String stableOperation) { + this.stableOperation = stableOperation; + return this; + } + + Builder hasCollection() { + this.hasCollection = true; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + Builder consumedCapacity(String consumedCapacity) { + this.consumedCapacity = consumedCapacity; + return this; + } + + Builder itemCollectionMetrics(String itemCollectionMetrics) { + this.itemCollectionMetrics = itemCollectionMetrics; + return this; + } + + Builder assertMetric() { + this.assertMetric = true; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } } private static String expectedDbOperationNameForSingleItemRequest(String operation) { diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index 17925b675ba1..a9494e25eea9 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -43,6 +43,7 @@ import java.time.Duration; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.Function; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -295,34 +296,33 @@ void testMetrics() { SERVER_PORT); } - @Test - void batchStatementWithSameQuery() { + // describes the batch cases: two statements with the same query, and two statements with + // different queries. (an empty batch is invalid CQL, and a single-statement batch is executed as + // a normal statement rather than a batch.) batch telemetry (db.operation.batch.size, BATCH span + // names and summaries) is only emitted under stable database semconv + @ParameterizedTest + @MethodSource("batchScenarios") + void batchStatement(BatchScenario scenario) { Session session = cluster.connect(); cleanup.deferCleanup(session); - session.execute("DROP KEYSPACE IF EXISTS batch_same_test"); + session.execute("DROP KEYSPACE IF EXISTS " + scenario.keyspace); + session.execute( + "CREATE KEYSPACE " + + scenario.keyspace + + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); session.execute( - "CREATE KEYSPACE batch_same_test WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); - session.execute("CREATE TABLE batch_same_test.users ( name text PRIMARY KEY, age int )"); - PreparedStatement preparedStatement = - session.prepare("INSERT INTO batch_same_test.users (name, age) values (?, ?)"); + "CREATE TABLE " + scenario.keyspace + ".users ( name text PRIMARY KEY, age int )"); testing.waitForTraces(3); testing.clearData(); - BatchStatement batchStatement = - new BatchStatement() - .add(preparedStatement.bind("alice", 1)) - .add(preparedStatement.bind("bob", 2)); - session.execute(batchStatement); + session.execute(scenario.buildBatch.apply(session)); testing.waitAndAssertTraces( trace -> trace.hasSpansSatisfyingExactly( span -> - span.hasName( - emitStableDatabaseSemconv() - ? "BATCH INSERT batch_same_test.users" - : "DB Query") + span.hasName(emitStableDatabaseSemconv() ? scenario.spanName : "DB Query") .hasKind(SpanKind.CLIENT) .hasNoParent() .hasAttributesSatisfyingExactly( @@ -334,63 +334,131 @@ void batchStatementWithSameQuery() { equalTo(maybeStable(DB_SYSTEM), CASSANDRA), equalTo( maybeStable(DB_STATEMENT), - emitStableDatabaseSemconv() - ? "INSERT INTO batch_same_test.users (name, age) values (?, ?)" - : null), + emitStableDatabaseSemconv() ? scenario.statement : null), equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo( DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() - ? "BATCH INSERT batch_same_test.users" - : null)))); + emitStableDatabaseSemconv() ? scenario.summary : null)))); } - @Test - void batchStatementWithDifferentQueries() { - Session session = cluster.connect(); - cleanup.deferCleanup(session); + private static Stream batchScenarios() { + return Stream.of( + BatchScenario.builder("twoSameOperation") + .keyspace("batch_same_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_same_test.users (name, age) values (?, ?)"); + return new BatchStatement() + .add(insert.bind("alice", 1)) + .add(insert.bind("bob", 2)); + }) + .spanName("BATCH INSERT batch_same_test.users") + .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") + .summary("BATCH INSERT batch_same_test.users") + .batchSize(2) + .build(), + BatchScenario.builder("twoDifferentOperations") + .keyspace("batch_mixed_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); + return new BatchStatement() + .add(insert.bind(1)) + .add( + new SimpleStatement( + "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); + }) + .spanName("BATCH") + .statement( + "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") + .summary("BATCH") + .batchSize(2) + .build()) + .map(Arguments::of); + } - session.execute("DROP KEYSPACE IF EXISTS batch_mixed_test"); - session.execute( - "CREATE KEYSPACE batch_mixed_test WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); - session.execute("CREATE TABLE batch_mixed_test.users ( name text PRIMARY KEY, age int )"); - PreparedStatement insertStatement = - session.prepare("INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); - testing.waitForTraces(3); - testing.clearData(); + private static final class BatchScenario { + final String name; + final String keyspace; + final Function buildBatch; + final String spanName; + final String statement; + final String summary; + final Long batchSize; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.keyspace = builder.keyspace; + this.buildBatch = builder.buildBatch; + this.spanName = builder.spanName; + this.statement = builder.statement; + this.summary = builder.summary; + this.batchSize = builder.batchSize; + } - BatchStatement batchStatement = - new BatchStatement() - .add(insertStatement.bind(1)) - .add( - new SimpleStatement( - "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); - session.execute(batchStatement); + @Override + public String toString() { + // used as the parameterized test display name + return name; + } - testing.waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> - span.hasName(emitStableDatabaseSemconv() ? "BATCH" : "DB Query") - .hasKind(SpanKind.CLIENT) - .hasNoParent() - .hasAttributesSatisfyingExactly( - equalTo(NETWORK_TYPE, emitStableDatabaseSemconv() ? null : "ipv4"), - equalTo(SERVER_ADDRESS, cassandraHost), - equalTo(SERVER_PORT, cassandraPort), - equalTo(NETWORK_PEER_ADDRESS, cassandraIp), - equalTo(NETWORK_PEER_PORT, cassandraPort), - equalTo(maybeStable(DB_SYSTEM), CASSANDRA), - equalTo( - maybeStable(DB_STATEMENT), - emitStableDatabaseSemconv() - ? "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?" - : null), - equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), - equalTo( - DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? "BATCH" : null)))); + static Builder builder(String name) { + return new Builder(name); + } + + static final class Builder { + private final String name; + private String keyspace; + private Function buildBatch; + private String spanName; + private String statement; + private String summary; + private Long batchSize; + + Builder(String name) { + this.name = name; + } + + Builder keyspace(String keyspace) { + this.keyspace = keyspace; + return this; + } + + Builder buildBatch(Function buildBatch) { + this.buildBatch = buildBatch; + return this; + } + + Builder spanName(String spanName) { + this.spanName = spanName; + return this; + } + + Builder statement(String statement) { + this.statement = statement; + return this; + } + + Builder summary(String summary) { + this.summary = summary; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } } private static Stream provideSyncParameters() { diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index 9964fb8e8530..a0791c00e506 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -50,6 +50,7 @@ import java.net.InetSocketAddress; import java.net.UnknownHostException; import java.time.Duration; +import java.util.function.Function; import java.util.stream.Stream; import javax.annotation.Nullable; import org.junit.jupiter.api.BeforeAll; @@ -186,36 +187,34 @@ void simpleStatementWithValues() { maybeStable(DB_CASSANDRA_TABLE), "simple_values_test.users")))); } - @Test - void batchStatementWithSameQuery() { + // describes the batch cases: two statements with the same query, and two statements with + // different queries. (an empty batch is invalid CQL, and a single-statement batch is executed as + // a normal statement rather than a batch.) batch telemetry (db.operation.batch.size, BATCH span + // names and summaries) is only emitted under stable database semconv + @ParameterizedTest + @MethodSource("batchScenarios") + void batchStatement(BatchScenario scenario) { CqlSession session = getSession(null); cleanup.deferCleanup(session); - session.execute("DROP KEYSPACE IF EXISTS batch_same_test"); + session.execute("DROP KEYSPACE IF EXISTS " + scenario.keyspace); + session.execute( + "CREATE KEYSPACE " + + scenario.keyspace + + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); session.execute( - "CREATE KEYSPACE batch_same_test WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); - session.execute("CREATE TABLE batch_same_test.users ( name text PRIMARY KEY, age int )"); - PreparedStatement preparedStatement = - session.prepare("INSERT INTO batch_same_test.users (name, age) values (?, ?)"); + "CREATE TABLE " + scenario.keyspace + ".users ( name text PRIMARY KEY, age int )"); testing().waitForTraces(3); testing().clearData(); - BatchStatement batchStatement = - BatchStatement.newInstance( - DefaultBatchType.LOGGED, - preparedStatement.bind("alice", 1), - preparedStatement.bind("bob", 2)); - session.execute(batchStatement); + session.execute(scenario.buildBatch.apply(session)); testing() .waitAndAssertTraces( trace -> trace.hasSpansSatisfyingExactly( span -> - span.hasName( - emitStableDatabaseSemconv() - ? "BATCH INSERT batch_same_test.users" - : "DB Query") + span.hasName(emitStableDatabaseSemconv() ? scenario.spanName : "DB Query") .hasKind(SpanKind.CLIENT) .hasNoParent() .hasAttributesSatisfyingExactly( @@ -231,17 +230,13 @@ void batchStatementWithSameQuery() { equalTo(maybeStable(DB_SYSTEM), CASSANDRA), equalTo( maybeStable(DB_STATEMENT), - emitStableDatabaseSemconv() - ? "INSERT INTO batch_same_test.users (name, age) values (?, ?)" - : null), + emitStableDatabaseSemconv() ? scenario.statement : null), equalTo( DB_OPERATION_BATCH_SIZE, - emitStableDatabaseSemconv() ? 2L : null), + emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo( DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() - ? "BATCH INSERT batch_same_test.users" - : null), + emitStableDatabaseSemconv() ? scenario.summary : null), equalTo(maybeStable(DB_CASSANDRA_CONSISTENCY_LEVEL), "LOCAL_ONE"), equalTo(maybeStable(DB_CASSANDRA_COORDINATOR_DC), "datacenter1"), satisfies( @@ -255,68 +250,121 @@ void batchStatementWithSameQuery() { maybeStable(DB_CASSANDRA_SPECULATIVE_EXECUTION_COUNT), 0)))); } - @Test - void batchStatementWithDifferentQueries() { - CqlSession session = getSession(null); - cleanup.deferCleanup(session); + private static Stream batchScenarios() { + return Stream.of( + BatchScenario.builder("twoSameOperation") + .keyspace("batch_same_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_same_test.users (name, age) values (?, ?)"); + return BatchStatement.newInstance( + DefaultBatchType.LOGGED, insert.bind("alice", 1), insert.bind("bob", 2)); + }) + .spanName("BATCH INSERT batch_same_test.users") + .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") + .summary("BATCH INSERT batch_same_test.users") + .batchSize(2) + .build(), + BatchScenario.builder("twoDifferentOperations") + .keyspace("batch_mixed_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); + return BatchStatement.newInstance( + DefaultBatchType.LOGGED, + insert.bind(1), + SimpleStatement.newInstance( + "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); + }) + .spanName("BATCH") + .statement( + "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") + .summary("BATCH") + .batchSize(2) + .build()) + .map(Arguments::of); + } - session.execute("DROP KEYSPACE IF EXISTS batch_mixed_test"); - session.execute( - "CREATE KEYSPACE batch_mixed_test WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); - session.execute("CREATE TABLE batch_mixed_test.users ( name text PRIMARY KEY, age int )"); - PreparedStatement insertStatement = - session.prepare("INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); - testing().waitForTraces(3); - testing().clearData(); + private static final class BatchScenario { + final String name; + final String keyspace; + final Function buildBatch; + final String spanName; + final String statement; + final String summary; + final Long batchSize; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.keyspace = builder.keyspace; + this.buildBatch = builder.buildBatch; + this.spanName = builder.spanName; + this.statement = builder.statement; + this.summary = builder.summary; + this.batchSize = builder.batchSize; + } - BatchStatement batchStatement = - BatchStatement.newInstance( - DefaultBatchType.LOGGED, - insertStatement.bind(1), - SimpleStatement.newInstance( - "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); - session.execute(batchStatement); + @Override + public String toString() { + // used as the parameterized test display name + return name; + } - testing() - .waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> - span.hasName(emitStableDatabaseSemconv() ? "BATCH" : "DB Query") - .hasKind(SpanKind.CLIENT) - .hasNoParent() - .hasAttributesSatisfyingExactly( - satisfies( - NETWORK_TYPE, - emitStableDatabaseSemconv() - ? val -> val.isNull() - : val -> val.isIn("ipv4", "ipv6")), - equalTo(SERVER_ADDRESS, cassandraHost), - equalTo(SERVER_PORT, cassandraPort), - equalTo(NETWORK_PEER_ADDRESS, cassandraIp), - equalTo(NETWORK_PEER_PORT, cassandraPort), - equalTo(maybeStable(DB_SYSTEM), CASSANDRA), - equalTo( - maybeStable(DB_STATEMENT), - emitStableDatabaseSemconv() - ? "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?" - : null), - equalTo( - DB_OPERATION_BATCH_SIZE, - emitStableDatabaseSemconv() ? 2L : null), - equalTo( - DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? "BATCH" : null), - equalTo(maybeStable(DB_CASSANDRA_CONSISTENCY_LEVEL), "LOCAL_ONE"), - equalTo(maybeStable(DB_CASSANDRA_COORDINATOR_DC), "datacenter1"), - satisfies( - maybeStable(DB_CASSANDRA_COORDINATOR_ID), - val -> val.isInstanceOf(String.class)), - satisfies( - maybeStable(DB_CASSANDRA_IDEMPOTENCE), - val -> val.isInstanceOf(Boolean.class)), - equalTo(maybeStable(DB_CASSANDRA_PAGE_SIZE), 5000), - equalTo( - maybeStable(DB_CASSANDRA_SPECULATIVE_EXECUTION_COUNT), 0)))); + static Builder builder(String name) { + return new Builder(name); + } + + static final class Builder { + private final String name; + private String keyspace; + private Function buildBatch; + private String spanName; + private String statement; + private String summary; + private Long batchSize; + + Builder(String name) { + this.name = name; + } + + Builder keyspace(String keyspace) { + this.keyspace = keyspace; + return this; + } + + Builder buildBatch(Function buildBatch) { + this.buildBatch = buildBatch; + return this; + } + + Builder spanName(String spanName) { + this.spanName = spanName; + return this; + } + + Builder statement(String statement) { + this.statement = statement; + return this; + } + + Builder summary(String summary) { + this.summary = summary; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } } @ParameterizedTest(name = "{index}: {0}") diff --git a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java index 5d5f71a5d426..3a645c0d89e4 100644 --- a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java +++ b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java @@ -18,6 +18,7 @@ import static io.opentelemetry.semconv.DbAttributes.DB_NAMESPACE; import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_SUMMARY; +import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_TEXT; import static io.opentelemetry.semconv.DbAttributes.DB_STORED_PROCEDURE_NAME; import static io.opentelemetry.semconv.DbAttributes.DB_SYSTEM_NAME; import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; @@ -1879,6 +1880,101 @@ void testProxyPreparedStatement() throws SQLException { .hasParent(trace.getSpan(0)))); } + // describes the four batch cases: the tables to create, the statements added to the batch, the + // expected executeBatch() result, and the expected client span. batch telemetry comes from the + // shared SQL extractors and is database-agnostic, so a single (in-memory) database is enough to + // lock down its shape + static Stream batchCasesStream() { + return Stream.of( + // an empty batch still produces a client span, but with no query text or batch size; + // the span name falls back to the database namespace + BatchScenario.builder().name("empty").spanName(DATABASE_NAME_LOWER).build(), + // a single-statement batch is not a batch (size 1), so it looks like a normal statement + BatchScenario.builder() + .name("single") + .createTable("stmt_batch_single") + .addQuery("INSERT INTO stmt_batch_single VALUES(1)") + .expectedResult(1) + .spanName("INSERT stmt_batch_single") + .queryText("INSERT INTO stmt_batch_single VALUES(?)") + .summary("INSERT stmt_batch_single") + .build(), + BatchScenario.builder() + .name("twoSameOperation") + .createTable("stmt_batch_same") + .addQuery("INSERT INTO stmt_batch_same VALUES(1)") + .addQuery("INSERT INTO stmt_batch_same VALUES(2)") + .expectedResult(1, 1) + .spanName("BATCH INSERT stmt_batch_same") + .queryText("INSERT INTO stmt_batch_same VALUES(?)") + .summary("BATCH INSERT stmt_batch_same") + .batchSize(2) + .build(), + BatchScenario.builder() + .name("twoDifferentOperations") + .createTable("stmt_batch_diff_1") + .createTable("stmt_batch_diff_2") + .addQuery("INSERT INTO stmt_batch_diff_1 VALUES(1)") + .addQuery("INSERT INTO stmt_batch_diff_2 VALUES(2)") + .expectedResult(1, 1) + .spanName("BATCH") + .queryText( + "INSERT INTO stmt_batch_diff_1 VALUES(?); INSERT INTO stmt_batch_diff_2" + + " VALUES(?)") + .summary("BATCH") + .batchSize(2) + .build()) + .map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("batchCasesStream") + void testStatementBatch(BatchScenario scenario) throws SQLException { + // batch telemetry (db.operation.batch.size, BATCH span names and summaries) is only emitted + // under stable database semconv + Assumptions.assumeTrue(emitStableDatabaseSemconv()); + + Connection connection = wrap(new org.h2.Driver().connect(JDBC_URLS.get("h2"), null)); + cleanup.deferCleanup(connection); + + for (String table : scenario.tablesToCreate) { + Statement createTable = connection.createStatement(); + createTable.execute("CREATE TABLE " + table + " (id INTEGER not NULL, PRIMARY KEY ( id ))"); + cleanup.deferCleanup(createTable); + } + if (!scenario.tablesToCreate.isEmpty()) { + testing().waitForTraces(scenario.tablesToCreate.size()); + testing().clearData(); + } + + Statement statement = connection.createStatement(); + cleanup.deferCleanup(statement); + for (String sql : scenario.statementsToAdd) { + statement.addBatch(sql); + } + + testing() + .runWithSpan( + "parent", + () -> assertThat(statement.executeBatch()).isEqualTo(scenario.expectedResult)); + + testing() + .waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(), + span -> + span.hasName(scenario.spanName) + .hasKind(SpanKind.CLIENT) + .hasParent(trace.getSpan(0)) + .hasAttributesSatisfyingExactly( + equalTo(DB_SYSTEM_NAME, maybeStableDbSystemName("h2")), + equalTo(DB_NAMESPACE, DATABASE_NAME_LOWER), + equalTo(DB_QUERY_TEXT, scenario.queryText), + equalTo(DB_QUERY_SUMMARY, scenario.summary), + equalTo(DB_OPERATION_BATCH_SIZE, scenario.batchSize)))); + } + static Stream batchStream() throws SQLException { return Stream.of( Arguments.of("h2", new org.h2.Driver().connect(JDBC_URLS.get("h2"), null), null, "h2:mem:"), @@ -1896,19 +1992,6 @@ static Stream batchStream() throws SQLException { "sqlite:memory:")); } - @ParameterizedTest - @MethodSource("batchStream") - void testBatch(String system, Connection connection, String username, String url) - throws SQLException { - testBatchImpl( - system, - wrap(connection), - username, - url, - "simple_batch_test", - statement -> assertThat(statement.executeBatch()).isEqualTo(new int[] {1, 1})); - } - @ParameterizedTest @MethodSource("batchStream") void testLargeBatch(String system, Connection connection, String username, String url) @@ -1933,170 +2016,6 @@ void testLargeBatch(String system, Connection connection, String username, Strin } } - private void testBatchImpl( - String system, - Connection connection, - String username, - String url, - String tableName, - ThrowingConsumer action) - throws SQLException { - Statement createTable = connection.createStatement(); - createTable.execute("CREATE TABLE " + tableName + " (id INTEGER not NULL, PRIMARY KEY ( id ))"); - cleanup.deferCleanup(createTable); - - testing().waitForTraces(1); - testing().clearData(); - - Statement statement = connection.createStatement(); - cleanup.deferCleanup(statement); - statement.addBatch("INSERT INTO non_existent_table VALUES(1)"); - statement.clearBatch(); - statement.addBatch("INSERT INTO " + tableName + " VALUES(1)"); - statement.addBatch("INSERT INTO " + tableName + " VALUES(2)"); - testing().runWithSpan("parent", () -> action.accept(statement)); - - testing() - .waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(), - span -> - span.hasName( - emitStableDatabaseSemconv() - ? "BATCH INSERT " + tableName - : "jdbcunittest") - .hasKind(SpanKind.CLIENT) - .hasParent(trace.getSpan(0)) - .hasAttributesSatisfyingExactly( - equalTo(maybeStable(DB_SYSTEM), maybeStableDbSystemName(system)), - equalTo(maybeStable(DB_NAME), DATABASE_NAME_LOWER), - equalTo(DB_USER, emitStableDatabaseSemconv() ? null : username), - equalTo( - DB_CONNECTION_STRING, emitStableDatabaseSemconv() ? null : url), - equalTo( - maybeStable(DB_STATEMENT), - emitStableDatabaseSemconv() - ? "INSERT INTO " + tableName + " VALUES(?)" - : null), - equalTo( - DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() - ? "BATCH INSERT " + tableName - : null), - equalTo( - DB_OPERATION_BATCH_SIZE, - emitStableDatabaseSemconv() ? 2L : null)))); - } - - @ParameterizedTest - @MethodSource("batchStream") - void testMultiBatch(String system, Connection conn, String username, String url) - throws SQLException { - Connection connection = wrap(conn); - String tableName1 = "multi_batch_test_1"; - String tableName2 = "multi_batch_test_2"; - Statement createTable1 = connection.createStatement(); - createTable1.execute( - "CREATE TABLE " + tableName1 + " (id INTEGER not NULL, PRIMARY KEY ( id ))"); - cleanup.deferCleanup(createTable1); - Statement createTable2 = connection.createStatement(); - createTable2.execute( - "CREATE TABLE " + tableName2 + " (id INTEGER not NULL, PRIMARY KEY ( id ))"); - cleanup.deferCleanup(createTable2); - - testing().waitForTraces(2); - testing().clearData(); - - Statement statement = connection.createStatement(); - cleanup.deferCleanup(statement); - statement.addBatch("INSERT INTO " + tableName1 + " VALUES(1)"); - statement.addBatch("INSERT INTO " + tableName2 + " VALUES(2)"); - testing() - .runWithSpan( - "parent", () -> assertThat(statement.executeBatch()).isEqualTo(new int[] {1, 1})); - - testing() - .waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(), - span -> - span.hasName(emitStableDatabaseSemconv() ? "BATCH" : "jdbcunittest") - .hasKind(SpanKind.CLIENT) - .hasParent(trace.getSpan(0)) - .hasAttributesSatisfyingExactly( - equalTo(maybeStable(DB_SYSTEM), maybeStableDbSystemName(system)), - equalTo(maybeStable(DB_NAME), DATABASE_NAME_LOWER), - equalTo(DB_USER, emitStableDatabaseSemconv() ? null : username), - equalTo( - DB_CONNECTION_STRING, emitStableDatabaseSemconv() ? null : url), - equalTo( - maybeStable(DB_STATEMENT), - emitStableDatabaseSemconv() - ? "INSERT INTO " - + tableName1 - + " VALUES(?); INSERT INTO multi_batch_test_2 VALUES(?)" - : null), - equalTo( - DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? "BATCH" : null), - equalTo(maybeStable(DB_OPERATION), null), - equalTo( - DB_OPERATION_BATCH_SIZE, - emitStableDatabaseSemconv() ? 2L : null)))); - } - - @ParameterizedTest - @MethodSource("batchStream") - void testSingleItemBatch(String system, Connection conn, String username, String url) - throws SQLException { - Connection connection = wrap(conn); - String tableName = "single_item_batch_test"; - Statement createTable = connection.createStatement(); - createTable.execute("CREATE TABLE " + tableName + " (id INTEGER not NULL, PRIMARY KEY ( id ))"); - cleanup.deferCleanup(createTable); - - testing().waitForTraces(1); - testing().clearData(); - - Statement statement = connection.createStatement(); - cleanup.deferCleanup(statement); - statement.addBatch("INSERT INTO " + tableName + " VALUES(1)"); - testing() - .runWithSpan("parent", () -> assertThat(statement.executeBatch()).isEqualTo(new int[] {1})); - - testing() - .waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(), - span -> - span.hasName( - emitStableDatabaseSemconv() - ? "INSERT " + tableName - : "INSERT jdbcunittest." + tableName) - .hasKind(SpanKind.CLIENT) - .hasParent(trace.getSpan(0)) - .hasAttributesSatisfyingExactly( - equalTo(maybeStable(DB_SYSTEM), maybeStableDbSystemName(system)), - equalTo(maybeStable(DB_NAME), DATABASE_NAME_LOWER), - equalTo(DB_USER, emitStableDatabaseSemconv() ? null : username), - equalTo( - DB_CONNECTION_STRING, emitStableDatabaseSemconv() ? null : url), - equalTo( - maybeStable(DB_STATEMENT), - "INSERT INTO " + tableName + " VALUES(?)"), - equalTo( - DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() ? "INSERT " + tableName : null), - equalTo( - maybeStable(DB_OPERATION), - emitStableDatabaseSemconv() ? null : "INSERT"), - equalTo( - maybeStable(DB_SQL_TABLE), - emitStableDatabaseSemconv() ? null : tableName)))); - } - @ParameterizedTest @MethodSource("batchStream") void testPreparedBatch(String system, Connection conn, String username, String url) @@ -2446,4 +2365,91 @@ void testStatementWrapper() throws SQLException { .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(1)))); } + + private static final class BatchScenario { + final String name; + final List tablesToCreate; + final List statementsToAdd; + final int[] expectedResult; + final String spanName; + final String queryText; + final String summary; + final Long batchSize; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.tablesToCreate = builder.tablesToCreate; + this.statementsToAdd = builder.statementsToAdd; + this.expectedResult = builder.expectedResult; + this.spanName = builder.spanName; + this.queryText = builder.queryText; + this.summary = builder.summary; + this.batchSize = builder.batchSize; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static Builder builder() { + return new Builder(); + } + + static final class Builder { + private String name; + private final List tablesToCreate = new ArrayList<>(); + private final List statementsToAdd = new ArrayList<>(); + private int[] expectedResult = new int[] {}; + private String spanName; + private String queryText; + private String summary; + private Long batchSize; + + Builder name(String name) { + this.name = name; + return this; + } + + Builder createTable(String table) { + this.tablesToCreate.add(table); + return this; + } + + Builder addQuery(String query) { + this.statementsToAdd.add(query); + return this; + } + + Builder expectedResult(int... result) { + this.expectedResult = result; + return this; + } + + Builder spanName(String spanName) { + this.spanName = spanName; + return this; + } + + Builder queryText(String queryText) { + this.queryText = queryText; + return this; + } + + Builder summary(String summary) { + this.summary = summary; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index 0732c04799f5..099e9830cfea 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -32,18 +32,22 @@ import static io.r2dbc.spi.ConnectionFactoryOptions.PASSWORD; import static io.r2dbc.spi.ConnectionFactoryOptions.PORT; import static io.r2dbc.spi.ConnectionFactoryOptions.USER; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.junit.jupiter.api.Named.named; import com.google.errorprone.annotations.CanIgnoreReturnValue; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; +import io.r2dbc.spi.Batch; import io.r2dbc.spi.ConnectionFactories; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.ConnectionFactoryOptions; import java.time.Duration; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.stream.Stream; import org.junit.jupiter.api.AfterAll; @@ -298,8 +302,12 @@ void testMetrics() { SERVER_PORT); } - @Test - void testBatchQueries() { + // describes the batch cases: a single-statement batch (not a batch -> no db.operation.batch.size) + // and two statements with the same operation. batch telemetry (db.operation.batch.size, BATCH + // span names and summaries) is only emitted under stable database semconv + @ParameterizedTest + @MethodSource("batchScenarios") + void batchQueries(BatchScenario scenario) { assumeTrue(emitStableDatabaseSemconv()); DbSystemProps props = systems.get(MARIADB.system); @@ -322,15 +330,15 @@ void testBatchQueries() { () -> { Mono.from(connectionFactory.create()) .flatMapMany( - connection -> - Flux.from( - connection - .createBatch() - .add("SELECT 1") - .add("SELECT 2") - .execute()) - .flatMap(result -> result.map((row, metadata) -> "")) - .concatWith(Mono.from(connection.close()).cast(String.class))) + connection -> { + Batch batch = connection.createBatch(); + for (String query : scenario.queries) { + batch.add(query); + } + return Flux.from(batch.execute()) + .flatMap(result -> result.map((row, metadata) -> "")) + .concatWith(Mono.from(connection.close()).cast(String.class)); + }) .blockLast(Duration.ofMinutes(1)); }); @@ -340,20 +348,57 @@ void testBatchQueries() { trace.hasSpansSatisfyingExactly( span -> span.hasName("parent").hasKind(SpanKind.INTERNAL), span -> - span.hasName("BATCH SELECT") + span.hasName(scenario.spanName) .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( equalTo(DB_SYSTEM_NAME, MARIADB.system), equalTo(DB_NAMESPACE, DB), equalTo(DB_QUERY_TEXT, "SELECT ?"), - equalTo(DB_QUERY_SUMMARY, "BATCH SELECT"), - equalTo(DB_OPERATION_BATCH_SIZE, 2), + equalTo(DB_QUERY_SUMMARY, scenario.summary), + equalTo(DB_OPERATION_BATCH_SIZE, scenario.batchSize), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, container.getHost()), equalTo(SERVER_PORT, port)))); } + private static Stream batchScenarios() { + return Stream.of( + // a single-statement batch is not a batch (size 1), so it emits no + // db.operation.batch.size and no BATCH prefix + new BatchScenario("single", singletonList("SELECT 1"), "SELECT", "SELECT", null), + new BatchScenario( + "twoSameOperation", + asList("SELECT 1", "SELECT 2"), + "BATCH SELECT", + "BATCH SELECT", + 2L)) + .map(Arguments::of); + } + + private static final class BatchScenario { + final String name; + final List queries; + final String spanName; + final String summary; + final Long batchSize; + + BatchScenario( + String name, List queries, String spanName, String summary, Long batchSize) { + this.name = name; + this.queries = queries; + this.spanName = spanName; + this.summary = summary; + this.batchSize = batchSize; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + } + private static class Parameter { private final String system; diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index abd694d9e56e..98f34919526d 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -42,6 +42,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.stream.Stream; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assumptions; @@ -52,6 +53,9 @@ import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.redisson.Redisson; import org.redisson.api.BatchOptions; import org.redisson.api.RAtomicLong; @@ -246,12 +250,16 @@ void stringCommand() { equalTo(maybeStable(DB_OPERATION), "GET")))); } - @Test - void batchCommand() throws ReflectiveOperationException { + // describes the batch cases: two commands with the same operation, and two commands with + // different operations. (a single-command batch is executed as a normal command rather than a + // pipeline.) batch telemetry (db.operation.batch.size, PIPELINE operation name) is only emitted + // under stable database semconv + @ParameterizedTest + @MethodSource("batchScenarios") + void batchCommand(BatchScenario scenario) throws ReflectiveOperationException { RBatch batch = createBatch(redisson); assertThat(batch).isNotNull(); - batch.getBucket("batch1").setAsync("v1"); - batch.getBucket("batch2").setAsync("v2"); + scenario.commands.accept(batch); // Adapt different method signature: // `BatchResult execute()` and `List execute()` invokeExecute(batch); @@ -259,7 +267,7 @@ void batchCommand() throws ReflectiveOperationException { trace -> trace.hasSpansSatisfyingExactly( span -> - span.hasName(emitStableDatabaseSemconv() ? "PIPELINE SET" : "DB Query") + span.hasName(emitStableDatabaseSemconv() ? scenario.operationName : "DB Query") .hasKind(CLIENT) .hasAttributesSatisfyingExactly( equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), @@ -268,41 +276,65 @@ void batchCommand() throws ReflectiveOperationException { equalTo(maybeStable(DB_SYSTEM), REDIS), equalTo( DB_OPERATION_NAME, - emitStableDatabaseSemconv() ? "PIPELINE SET" : null), + emitStableDatabaseSemconv() ? scenario.operationName : null), equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), - equalTo(maybeStable(DB_STATEMENT), "SET batch1 ?;SET batch2 ?")))); + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? scenario.batchSize : null), + equalTo(maybeStable(DB_STATEMENT), scenario.statement)))); } - private static void invokeExecute(RBatch batch) throws ReflectiveOperationException { - batch.getClass().getMethod("execute").invoke(batch); + private static Stream batchScenarios() { + return Stream.of( + new BatchScenario( + "twoSameOperation", + batch -> { + batch.getBucket("batch1").setAsync("v1"); + batch.getBucket("batch2").setAsync("v2"); + }, + "PIPELINE SET", + 2L, + "SET batch1 ?;SET batch2 ?"), + new BatchScenario( + "twoDifferentOperations", + batch -> { + batch.getBucket("batch1").setAsync("v1"); + batch.getBucket("batch1").getAsync(); + }, + "PIPELINE", + 2L, + "SET batch1 ?;GET batch1")) + .map(Arguments::of); } - @Test - void mixedBatchCommand() throws ReflectiveOperationException { - RBatch batch = createBatch(redisson); - assertThat(batch).isNotNull(); - batch.getBucket("batch1").setAsync("v1"); - batch.getBucket("batch1").getAsync(); - // Adapt different method signature: - // `BatchResult execute()` and `List execute()` - invokeExecute(batch); - testing.waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> - span.hasName(emitStableDatabaseSemconv() ? "PIPELINE" : "DB Query") - .hasKind(CLIENT) - .hasAttributesSatisfyingExactly( - equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), - equalTo(NETWORK_PEER_ADDRESS, ip), - equalTo(NETWORK_PEER_PORT, port), - equalTo(maybeStable(DB_SYSTEM), REDIS), - equalTo( - DB_OPERATION_NAME, emitStableDatabaseSemconv() ? "PIPELINE" : null), - equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), - equalTo(maybeStable(DB_STATEMENT), "SET batch1 ?;GET batch1")))); + private static final class BatchScenario { + final String name; + final Consumer commands; + final String operationName; + final Long batchSize; + final String statement; + + BatchScenario( + String name, + Consumer commands, + String operationName, + Long batchSize, + String statement) { + this.name = name; + this.commands = commands; + this.operationName = operationName; + this.batchSize = batchSize; + this.statement = statement; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + } + + private static void invokeExecute(RBatch batch) throws ReflectiveOperationException { + batch.getClass().getMethod("execute").invoke(batch); } @Test diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java index b80f6d7468f6..17abe5274588 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java @@ -29,6 +29,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_USER; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.POSTGRESQL; import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; @@ -53,9 +54,13 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.containers.GenericContainer; @@ -280,14 +285,18 @@ private static void assertPreparedSelect() { equalTo(SERVER_PORT, port)))); } - @Test - void testBatch() throws Exception { + // describes the batch cases: a single-statement batch (not a batch -> no db.operation.batch.size) + // and two statements. batch telemetry (db.operation.batch.size, BATCH span names and summaries) + // is only emitted under stable database semconv + @ParameterizedTest + @MethodSource("batchScenarios") + void testBatch(BatchScenario scenario) throws Exception { testing .runWithSpan( "parent", () -> pool.preparedQuery("insert into test values ($1, $2) returning *") - .executeBatch(asList(Tuple.of(3, "Three"), Tuple.of(4, "Four")))) + .executeBatch(scenario.tuples)) .toCompletionStage() .toCompletableFuture() .get(30, SECONDS); @@ -299,7 +308,7 @@ void testBatch() throws Exception { span -> span.hasName( emitStableDatabaseSemconv() - ? "BATCH insert test" + ? scenario.stableSpanName : "INSERT tempdb.test") .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) @@ -314,9 +323,10 @@ void testBatch() throws Exception { "insert into test values ($1, $2) returning *"), equalTo( DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() ? "BATCH insert test" : null), + emitStableDatabaseSemconv() ? scenario.stableSummary : null), equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo( maybeStable(DB_OPERATION), emitStableDatabaseSemconv() ? null : "INSERT"), @@ -328,6 +338,48 @@ DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), equalTo(SERVER_PORT, port)))); } + private static Stream batchScenarios() { + return Stream.of( + // a single-statement batch is not a batch (size 1), so it emits no + // db.operation.batch.size and no BATCH prefix + new BatchScenario( + "single", singletonList(Tuple.of(3, "Three")), "insert test", "insert test", null), + new BatchScenario( + "twoSameOperation", + asList(Tuple.of(4, "Four"), Tuple.of(5, "Five")), + "BATCH insert test", + "BATCH insert test", + 2L)) + .map(Arguments::of); + } + + private static final class BatchScenario { + final String name; + final List tuples; + final String stableSpanName; + final String stableSummary; + final Long batchSize; + + BatchScenario( + String name, + List tuples, + String stableSpanName, + String stableSummary, + Long batchSize) { + this.name = name; + this.tuples = tuples; + this.stableSpanName = stableSpanName; + this.stableSummary = stableSummary; + this.batchSize = batchSize; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + } + @Test void testWithTransaction() throws Exception { testing diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java index d9b4bd36a82a..fe7621c5bf63 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java @@ -29,6 +29,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_USER; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.POSTGRESQL; import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; @@ -53,9 +54,13 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.containers.GenericContainer; @@ -281,14 +286,18 @@ private static void assertPreparedSelect() { equalTo(SERVER_PORT, port)))); } - @Test - void testBatch() throws Exception { + // describes the batch cases: a single-statement batch (not a batch -> no db.operation.batch.size) + // and two statements. batch telemetry (db.operation.batch.size, BATCH span names and summaries) + // is only emitted under stable database semconv + @ParameterizedTest + @MethodSource("batchScenarios") + void testBatch(BatchScenario scenario) throws Exception { testing .runWithSpan( "parent", () -> pool.preparedQuery("insert into test values ($1, $2) returning *") - .executeBatch(asList(Tuple.of(3, "Three"), Tuple.of(4, "Four")))) + .executeBatch(scenario.tuples)) .toCompletionStage() .toCompletableFuture() .get(30, SECONDS); @@ -300,7 +309,7 @@ void testBatch() throws Exception { span -> span.hasName( emitStableDatabaseSemconv() - ? "BATCH insert test" + ? scenario.stableSpanName : "INSERT tempdb.test") .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) @@ -315,9 +324,10 @@ void testBatch() throws Exception { "insert into test values ($1, $2) returning *"), equalTo( DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() ? "BATCH insert test" : null), + emitStableDatabaseSemconv() ? scenario.stableSummary : null), equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo( maybeStable(DB_OPERATION), emitStableDatabaseSemconv() ? null : "INSERT"), @@ -329,6 +339,48 @@ DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), equalTo(SERVER_PORT, port)))); } + private static Stream batchScenarios() { + return Stream.of( + // a single-statement batch is not a batch (size 1), so it emits no + // db.operation.batch.size and no BATCH prefix + new BatchScenario( + "single", singletonList(Tuple.of(3, "Three")), "insert test", "insert test", null), + new BatchScenario( + "twoSameOperation", + asList(Tuple.of(4, "Four"), Tuple.of(5, "Five")), + "BATCH insert test", + "BATCH insert test", + 2L)) + .map(Arguments::of); + } + + private static final class BatchScenario { + final String name; + final List tuples; + final String stableSpanName; + final String stableSummary; + final Long batchSize; + + BatchScenario( + String name, + List tuples, + String stableSpanName, + String stableSummary, + Long batchSize) { + this.name = name; + this.tuples = tuples; + this.stableSpanName = stableSpanName; + this.stableSummary = stableSummary; + this.batchSize = batchSize; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + } + @Test void testWithTransaction() throws Exception { testing From 183be88c834350bbc4f545462b4dbd29a4f7db0d Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 11:27:38 -0700 Subject: [PATCH 02/31] Add single-statement (not-a-batch) case to Cassandra and Redisson batch tests --- .../cassandra/v3_0/CassandraClientTest.java | 76 ++++++++++++++++-- .../common/v4_0/AbstractCassandraTest.java | 77 +++++++++++++++++-- .../redisson/AbstractRedissonClientTest.java | 51 +++++++++--- 3 files changed, 182 insertions(+), 22 deletions(-) diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index a9494e25eea9..94bba11c2d99 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -296,10 +296,10 @@ void testMetrics() { SERVER_PORT); } - // describes the batch cases: two statements with the same query, and two statements with - // different queries. (an empty batch is invalid CQL, and a single-statement batch is executed as - // a normal statement rather than a batch.) batch telemetry (db.operation.batch.size, BATCH span - // names and summaries) is only emitted under stable database semconv + // describes the batch cases: a single-statement batch (which is executed as a normal statement, + // not a batch), two statements with the same query, and two statements with different queries. (an + // empty batch is invalid CQL.) batch telemetry (db.operation.batch.size, BATCH span names and + // summaries) is only emitted under stable database semconv @ParameterizedTest @MethodSource("batchScenarios") void batchStatement(BatchScenario scenario) { @@ -322,7 +322,8 @@ void batchStatement(BatchScenario scenario) { trace -> trace.hasSpansSatisfyingExactly( span -> - span.hasName(emitStableDatabaseSemconv() ? scenario.spanName : "DB Query") + span.hasName( + emitStableDatabaseSemconv() ? scenario.spanName : scenario.oldSpanName) .hasKind(SpanKind.CLIENT) .hasNoParent() .hasAttributesSatisfyingExactly( @@ -332,19 +333,46 @@ void batchStatement(BatchScenario scenario) { equalTo(NETWORK_PEER_ADDRESS, cassandraIp), equalTo(NETWORK_PEER_PORT, cassandraPort), equalTo(maybeStable(DB_SYSTEM), CASSANDRA), + // a single-statement batch is not a batch, so it carries the normal + // statement's db.operation and db.cassandra.table; the real batch cases + // do not equalTo( maybeStable(DB_STATEMENT), - emitStableDatabaseSemconv() ? scenario.statement : null), + emitStableDatabaseSemconv() + ? scenario.statement + : scenario.oldStatement), equalTo( DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo( DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() ? scenario.summary : null)))); + emitStableDatabaseSemconv() ? scenario.summary : null), + equalTo(maybeStable(DB_OPERATION), scenario.operation), + equalTo(maybeStable(DB_CASSANDRA_TABLE), scenario.table)))); } private static Stream batchScenarios() { return Stream.of( + // a single-statement batch is executed as a normal statement (not a batch): it has the + // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no + // db.operation.batch.size + BatchScenario.builder("single") + .keyspace("batch_single_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_single_test.users (name, age) values (?, ?)"); + return new BatchStatement().add(insert.bind("alice", 1)); + }) + .spanName("INSERT batch_single_test.users") + .oldSpanName("INSERT batch_single_test.users") + .statement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") + .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") + .summary("INSERT batch_single_test.users") + .operation("INSERT") + .table("batch_single_test.users") + .build(), BatchScenario.builder("twoSameOperation") .keyspace("batch_same_test") .buildBatch( @@ -357,6 +385,7 @@ private static Stream batchScenarios() { .add(insert.bind("bob", 2)); }) .spanName("BATCH INSERT batch_same_test.users") + .oldSpanName("DB Query") .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") .summary("BATCH INSERT batch_same_test.users") .batchSize(2) @@ -375,6 +404,7 @@ private static Stream batchScenarios() { "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); }) .spanName("BATCH") + .oldSpanName("DB Query") .statement( "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") .summary("BATCH") @@ -388,18 +418,26 @@ private static final class BatchScenario { final String keyspace; final Function buildBatch; final String spanName; + final String oldSpanName; final String statement; + final String oldStatement; final String summary; final Long batchSize; + final String operation; + final String table; BatchScenario(Builder builder) { this.name = builder.name; this.keyspace = builder.keyspace; this.buildBatch = builder.buildBatch; this.spanName = builder.spanName; + this.oldSpanName = builder.oldSpanName; this.statement = builder.statement; + this.oldStatement = builder.oldStatement; this.summary = builder.summary; this.batchSize = builder.batchSize; + this.operation = builder.operation; + this.table = builder.table; } @Override @@ -417,9 +455,13 @@ static final class Builder { private String keyspace; private Function buildBatch; private String spanName; + private String oldSpanName; private String statement; + private String oldStatement; private String summary; private Long batchSize; + private String operation; + private String table; Builder(String name) { this.name = name; @@ -440,11 +482,21 @@ Builder spanName(String spanName) { return this; } + Builder oldSpanName(String oldSpanName) { + this.oldSpanName = oldSpanName; + return this; + } + Builder statement(String statement) { this.statement = statement; return this; } + Builder oldStatement(String oldStatement) { + this.oldStatement = oldStatement; + return this; + } + Builder summary(String summary) { this.summary = summary; return this; @@ -455,6 +507,16 @@ Builder batchSize(long batchSize) { return this; } + Builder operation(String operation) { + this.operation = operation; + return this; + } + + Builder table(String table) { + this.table = table; + return this; + } + BatchScenario build() { return new BatchScenario(this); } diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index a0791c00e506..5bb740d99697 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -187,10 +187,10 @@ void simpleStatementWithValues() { maybeStable(DB_CASSANDRA_TABLE), "simple_values_test.users")))); } - // describes the batch cases: two statements with the same query, and two statements with - // different queries. (an empty batch is invalid CQL, and a single-statement batch is executed as - // a normal statement rather than a batch.) batch telemetry (db.operation.batch.size, BATCH span - // names and summaries) is only emitted under stable database semconv + // describes the batch cases: a single-statement batch (which is executed as a normal statement, + // not a batch), two statements with the same query, and two statements with different queries. (an + // empty batch is invalid CQL.) batch telemetry (db.operation.batch.size, BATCH span names and + // summaries) is only emitted under stable database semconv @ParameterizedTest @MethodSource("batchScenarios") void batchStatement(BatchScenario scenario) { @@ -214,7 +214,10 @@ void batchStatement(BatchScenario scenario) { trace -> trace.hasSpansSatisfyingExactly( span -> - span.hasName(emitStableDatabaseSemconv() ? scenario.spanName : "DB Query") + span.hasName( + emitStableDatabaseSemconv() + ? scenario.spanName + : scenario.oldSpanName) .hasKind(SpanKind.CLIENT) .hasNoParent() .hasAttributesSatisfyingExactly( @@ -230,13 +233,20 @@ void batchStatement(BatchScenario scenario) { equalTo(maybeStable(DB_SYSTEM), CASSANDRA), equalTo( maybeStable(DB_STATEMENT), - emitStableDatabaseSemconv() ? scenario.statement : null), + emitStableDatabaseSemconv() + ? scenario.statement + : scenario.oldStatement), equalTo( DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo( DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? scenario.summary : null), + // a single-statement batch is not a batch, so it carries the normal + // statement's db.operation and db.cassandra.table; the real batch + // cases do not + equalTo(maybeStable(DB_OPERATION), scenario.operation), + equalTo(maybeStable(DB_CASSANDRA_TABLE), scenario.table), equalTo(maybeStable(DB_CASSANDRA_CONSISTENCY_LEVEL), "LOCAL_ONE"), equalTo(maybeStable(DB_CASSANDRA_COORDINATOR_DC), "datacenter1"), satisfies( @@ -252,6 +262,27 @@ void batchStatement(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( + // a single-statement batch is executed as a normal statement (not a batch): it has the + // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no + // db.operation.batch.size + BatchScenario.builder("single") + .keyspace("batch_single_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_single_test.users (name, age) values (?, ?)"); + return BatchStatement.newInstance( + DefaultBatchType.LOGGED, insert.bind("alice", 1)); + }) + .spanName("INSERT batch_single_test.users") + .oldSpanName("INSERT batch_single_test.users") + .statement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") + .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") + .summary("INSERT batch_single_test.users") + .operation("INSERT") + .table("batch_single_test.users") + .build(), BatchScenario.builder("twoSameOperation") .keyspace("batch_same_test") .buildBatch( @@ -263,6 +294,7 @@ private static Stream batchScenarios() { DefaultBatchType.LOGGED, insert.bind("alice", 1), insert.bind("bob", 2)); }) .spanName("BATCH INSERT batch_same_test.users") + .oldSpanName("DB Query") .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") .summary("BATCH INSERT batch_same_test.users") .batchSize(2) @@ -281,6 +313,7 @@ private static Stream batchScenarios() { "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); }) .spanName("BATCH") + .oldSpanName("DB Query") .statement( "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") .summary("BATCH") @@ -294,18 +327,26 @@ private static final class BatchScenario { final String keyspace; final Function buildBatch; final String spanName; + final String oldSpanName; final String statement; + final String oldStatement; final String summary; final Long batchSize; + final String operation; + final String table; BatchScenario(Builder builder) { this.name = builder.name; this.keyspace = builder.keyspace; this.buildBatch = builder.buildBatch; this.spanName = builder.spanName; + this.oldSpanName = builder.oldSpanName; this.statement = builder.statement; + this.oldStatement = builder.oldStatement; this.summary = builder.summary; this.batchSize = builder.batchSize; + this.operation = builder.operation; + this.table = builder.table; } @Override @@ -323,9 +364,13 @@ static final class Builder { private String keyspace; private Function buildBatch; private String spanName; + private String oldSpanName; private String statement; + private String oldStatement; private String summary; private Long batchSize; + private String operation; + private String table; Builder(String name) { this.name = name; @@ -346,11 +391,21 @@ Builder spanName(String spanName) { return this; } + Builder oldSpanName(String oldSpanName) { + this.oldSpanName = oldSpanName; + return this; + } + Builder statement(String statement) { this.statement = statement; return this; } + Builder oldStatement(String oldStatement) { + this.oldStatement = oldStatement; + return this; + } + Builder summary(String summary) { this.summary = summary; return this; @@ -361,6 +416,16 @@ Builder batchSize(long batchSize) { return this; } + Builder operation(String operation) { + this.operation = operation; + return this; + } + + Builder table(String table) { + this.table = table; + return this; + } + BatchScenario build() { return new BatchScenario(this); } diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index 98f34919526d..4179e3ff9c64 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -250,10 +250,10 @@ void stringCommand() { equalTo(maybeStable(DB_OPERATION), "GET")))); } - // describes the batch cases: two commands with the same operation, and two commands with - // different operations. (a single-command batch is executed as a normal command rather than a - // pipeline.) batch telemetry (db.operation.batch.size, PIPELINE operation name) is only emitted - // under stable database semconv + // describes the batch cases: a single-command batch (which is executed as a normal command, not a + // pipeline), two commands with the same operation, and two commands with different operations. + // batch telemetry (db.operation.batch.size, PIPELINE operation name) is only emitted under stable + // database semconv @ParameterizedTest @MethodSource("batchScenarios") void batchCommand(BatchScenario scenario) throws ReflectiveOperationException { @@ -267,24 +267,42 @@ void batchCommand(BatchScenario scenario) throws ReflectiveOperationException { trace -> trace.hasSpansSatisfyingExactly( span -> - span.hasName(emitStableDatabaseSemconv() ? scenario.operationName : "DB Query") + span.hasName(emitStableDatabaseSemconv() ? scenario.spanName : scenario.oldSpanName) .hasKind(CLIENT) .hasAttributesSatisfyingExactly( equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), equalTo(NETWORK_PEER_ADDRESS, ip), equalTo(NETWORK_PEER_PORT, port), equalTo(maybeStable(DB_SYSTEM), REDIS), + // a single-command batch is not a batch, so in stable mode it is named + // after the command (db.operation.name = SET) and in old mode carries + // db.operation = SET; the real pipeline cases carry db.operation.name = + // PIPELINE* (stable only) and db.operation.batch.size equalTo( DB_OPERATION_NAME, - emitStableDatabaseSemconv() ? scenario.operationName : null), + emitStableDatabaseSemconv() ? scenario.operationName() : null), equalTo( DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? scenario.batchSize : null), + equalTo( + DB_OPERATION, + emitStableDatabaseSemconv() ? null : scenario.oldOperation), equalTo(maybeStable(DB_STATEMENT), scenario.statement)))); } private static Stream batchScenarios() { return Stream.of( + // a single-command batch is executed as a normal command (not a pipeline): the span is + // named after the command, it carries db.operation (old) / db.operation.name (stable), + // and emits no db.operation.batch.size + new BatchScenario( + "single", + batch -> batch.getBucket("batch1").setAsync("v1"), + "SET", + "SET", + null, + "SET", + "SET batch1 ?"), new BatchScenario( "twoSameOperation", batch -> { @@ -292,7 +310,9 @@ private static Stream batchScenarios() { batch.getBucket("batch2").setAsync("v2"); }, "PIPELINE SET", + "DB Query", 2L, + null, "SET batch1 ?;SET batch2 ?"), new BatchScenario( "twoDifferentOperations", @@ -301,7 +321,9 @@ private static Stream batchScenarios() { batch.getBucket("batch1").getAsync(); }, "PIPELINE", + "DB Query", 2L, + null, "SET batch1 ?;GET batch1")) .map(Arguments::of); } @@ -309,23 +331,34 @@ private static Stream batchScenarios() { private static final class BatchScenario { final String name; final Consumer commands; - final String operationName; + final String spanName; + final String oldSpanName; final Long batchSize; + final String oldOperation; final String statement; BatchScenario( String name, Consumer commands, - String operationName, + String spanName, + String oldSpanName, Long batchSize, + String oldOperation, String statement) { this.name = name; this.commands = commands; - this.operationName = operationName; + this.spanName = spanName; + this.oldSpanName = oldSpanName; this.batchSize = batchSize; + this.oldOperation = oldOperation; this.statement = statement; } + // the stable-mode db.operation.name (= the span name in stable mode) + String operationName() { + return spanName; + } + @Override public String toString() { // used as the parameterized test display name From 7417433f86824b4ba67fbf3df8c39135341f0cc9 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 12:12:39 -0700 Subject: [PATCH 03/31] Make DynamoDB 2.2 batch test cover empty/single/two for both operations --- .../v2_2/AbstractAws2ClientCoreTest.java | 157 +++++++----------- 1 file changed, 64 insertions(+), 93 deletions(-) diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java index 3fc1afaa4b38..f15856104e52 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java @@ -172,7 +172,6 @@ private void validateOperationResponse(String operation, Object response) { case "ListTables": assertListTablesRequest(span); return; - case "BatchGetItem": case "GetItem": assertDynamoDbRequest( span, @@ -183,19 +182,6 @@ private void validateOperationResponse(String operation, Object response) { singletonList( "{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}")))); return; - case "BatchWriteItem": - assertDynamoDbRequest( - span, - operation, - asList( - equalTo( - AWS_DYNAMODB_CONSUMED_CAPACITY, - singletonList( - "{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}")), - equalTo( - AWS_DYNAMODB_ITEM_COLLECTION_METRICS, - "[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]"))); - return; case "DeleteItem": case "PutItem": case "UpdateItem": @@ -294,16 +280,6 @@ private static void assertListTablesRequest(SpanDataAssert span) { @SuppressWarnings("deprecation") // uses deprecated semconv private static void assertDynamoDbRequest( SpanDataAssert span, String operation, List extraAttributes) { - assertDynamoDbRequest( - span, operation, extraAttributes, expectedDbOperationNameForSingleItemRequest(operation)); - } - - @SuppressWarnings("deprecation") // uses deprecated semconv - private static void assertDynamoDbRequest( - SpanDataAssert span, - String operation, - List extraAttributes, - String expectedStableOperationName) { List assertions = new ArrayList<>( asList( @@ -319,9 +295,7 @@ private static void assertDynamoDbRequest( equalTo(AWS_REQUEST_ID, "UNKNOWN"), equalTo(AWS_DYNAMODB_TABLE_NAMES, singletonList("sometable")), equalTo(maybeStable(DB_SYSTEM), maybeStableDbSystemName(DYNAMODB)), - equalTo( - maybeStable(DB_OPERATION), - emitStableDatabaseSemconv() ? expectedStableOperationName : operation))); + equalTo(maybeStable(DB_OPERATION), operation))); if (emitStableDatabaseSemconv()) { assertions.add(equalTo(DB_COLLECTION_NAME, "sometable")); } @@ -433,56 +407,7 @@ private static Stream provideArguments() { "UpdateTable", (Function) c -> c.updateTable(b -> b.tableName("sometable"))), Arguments.of( - "Scan", (Function) c -> c.scan(b -> b.tableName("sometable"))), - Arguments.of( - "BatchGetItem", - (Function) - c -> - c.batchGetItem( - b -> - b.requestItems( - ImmutableMap.of( - "sometable", - KeysAndAttributes.builder() - .keys( - singletonList( - ImmutableMap.of( - "keyOne", - AttributeValue.builder().s("value").build(), - "keyTwo", - AttributeValue.builder() - .s("differentValue") - .build()))) - .build())))), - Arguments.of( - "BatchWriteItem", - (Function) - c -> - c.batchWriteItem( - b -> - b.requestItems( - ImmutableMap.of( - "sometable", - singletonList( - WriteRequest.builder() - .putRequest( - PutRequest.builder() - .item( - ImmutableMap.of( - "key", - AttributeValue.builder() - .s("value") - .build(), - "attributeOne", - AttributeValue.builder() - .s("one") - .build(), - "attributeTwo", - AttributeValue.builder() - .s("two") - .build())) - .build()) - .build())))))); + "Scan", (Function) c -> c.scan(b -> b.tableName("sometable")))); } @ParameterizedTest @@ -648,6 +573,32 @@ private static Stream batchScenarios() { .execute(c -> c.batchGetItem(b -> b.requestItems(ImmutableMap.of()))) .stableOperation("BatchGetItem") .build(), + // a single-item batch is not a batch, so it uses the singular item operation and emits + // no db.operation.batch.size + BatchScenario.builder("getItemSingle") + .awsOperation("BatchGetItem") + .responseContent(getResponseContent("BatchGetItem")) + .execute( + c -> + c.batchGetItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + KeysAndAttributes.builder() + .keys( + singletonList( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build()))) + .build())))) + .stableOperation("GetItem") + .hasCollection() + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .assertMetric() + .build(), BatchScenario.builder("getItemTwo") .awsOperation("BatchGetItem") .responseContent(getResponseContent("BatchGetItem")) @@ -678,6 +629,42 @@ private static Stream batchScenarios() { .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") .assertMetric() .build(), + BatchScenario.builder("writeItemEmpty") + .awsOperation("BatchWriteItem") + .responseContent("{\"ConsumedCapacity\":[]}") + .execute(c -> c.batchWriteItem(b -> b.requestItems(ImmutableMap.of()))) + .stableOperation("BatchWriteItem") + .build(), + // a single-item batch is not a batch, so it uses the singular item operation and emits + // no db.operation.batch.size + BatchScenario.builder("writeItemSingle") + .awsOperation("BatchWriteItem") + .responseContent(getResponseContent("BatchWriteItem")) + .execute( + c -> + c.batchWriteItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + singletonList( + WriteRequest.builder() + .putRequest( + PutRequest.builder() + .item( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build())) + .build()) + .build()))))) + .stableOperation("WriteItem") + .hasCollection() + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() + .build(), BatchScenario.builder("writeItemTwo") .awsOperation("BatchWriteItem") .responseContent(getResponseContent("BatchWriteItem")) @@ -839,22 +826,6 @@ BatchScenario build() { } } - private static String expectedDbOperationNameForSingleItemRequest(String operation) { - if (!emitStableDatabaseSemconv()) { - return operation; - } - // The parameterized Batch* requests contain one item. Stable DB semconv treats those as - // logical item operations; dedicated multi-item tests pass the BATCH operation name directly. - switch (operation) { - case "BatchGetItem": - return "GetItem"; - case "BatchWriteItem": - return "WriteItem"; - default: - return operation; - } - } - private static String getResponseContent(String operation) { switch (operation) { case "ListTables": From 7adfc61ed66b3a6bbfd620281637e638303f27de Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 12:29:09 -0700 Subject: [PATCH 04/31] Add empty-batch case to Cassandra batch tests --- .../cassandra/v3_0/CassandraClientTest.java | 8 ++++++++ .../cassandra/common/v4_0/AbstractCassandraTest.java | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index 94bba11c2d99..196f8f144071 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -353,6 +353,14 @@ void batchStatement(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( + // an empty batch still produces a client span, but with no query text, summary, + // operation or batch size; the span name falls back to the database system name + BatchScenario.builder("empty") + .keyspace("batch_empty_test") + .buildBatch(session -> new BatchStatement()) + .spanName("cassandra") + .oldSpanName("DB Query") + .build(), // a single-statement batch is executed as a normal statement (not a batch): it has the // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no // db.operation.batch.size diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index 5bb740d99697..56f43f428a3b 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -262,6 +262,14 @@ void batchStatement(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( + // an empty batch still produces a client span, but with no query text, summary, + // operation or batch size; the span name falls back to the database system name + BatchScenario.builder("empty") + .keyspace("batch_empty_test") + .buildBatch(session -> BatchStatement.newInstance(DefaultBatchType.LOGGED)) + .spanName("cassandra") + .oldSpanName("DB Query") + .build(), // a single-statement batch is executed as a normal statement (not a batch): it has the // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no // db.operation.batch.size From 0f928f2c33dd7b97f521677bb12611cb62a5d839 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 12:53:40 -0700 Subject: [PATCH 05/31] Add empty-batch case to R2DBC, Vert.x SQL and Redisson batch tests --- .../v1_0/AbstractR2dbcStatementTest.java | 76 ++++++++++++++----- .../redisson/AbstractRedissonClientTest.java | 32 +++++++- .../sqlclient/v4_0/VertxSqlClientTest.java | 51 ++++++++++--- .../sqlclient/v5_0/VertxSqlClientTest.java | 51 ++++++++++--- 4 files changed, 165 insertions(+), 45 deletions(-) diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index 099e9830cfea..fd51de6c5a6b 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -16,6 +16,7 @@ import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_SUMMARY; import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_TEXT; import static io.opentelemetry.semconv.DbAttributes.DB_SYSTEM_NAME; +import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; import static io.opentelemetry.semconv.ServerAttributes.SERVER_PORT; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_CONNECTION_STRING; @@ -33,7 +34,10 @@ import static io.r2dbc.spi.ConnectionFactoryOptions.PORT; import static io.r2dbc.spi.ConnectionFactoryOptions.USER; import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.junit.jupiter.api.Named.named; @@ -49,6 +53,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.stream.Stream; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Test; @@ -302,9 +307,10 @@ void testMetrics() { SERVER_PORT); } - // describes the batch cases: a single-statement batch (not a batch -> no db.operation.batch.size) - // and two statements with the same operation. batch telemetry (db.operation.batch.size, BATCH - // span names and summaries) is only emitted under stable database semconv + // describes the batch cases: an empty batch (no statements -> no client span), a single-statement + // batch (not a batch -> no db.operation.batch.size) and two statements with the same operation. + // batch telemetry (db.operation.batch.size, BATCH span names and summaries) is only emitted under + // stable database semconv @ParameterizedTest @MethodSource("batchScenarios") void batchQueries(BatchScenario scenario) { @@ -324,24 +330,52 @@ void batchQueries(BatchScenario scenario) { .option(CONNECT_TIMEOUT, Duration.ofSeconds(30)) .build()); - getTesting() - .runWithSpan( - "parent", - () -> { - Mono.from(connectionFactory.create()) - .flatMapMany( - connection -> { - Batch batch = connection.createBatch(); - for (String query : scenario.queries) { - batch.add(query); - } - return Flux.from(batch.execute()) - .flatMap(result -> result.map((row, metadata) -> "")) - .concatWith(Mono.from(connection.close()).cast(String.class)); - }) - .blockLast(Duration.ofMinutes(1)); - }); + Throwable thrown = + catchThrowable( + () -> + getTesting() + .runWithSpan( + "parent", + () -> { + Mono.from(connectionFactory.create()) + .flatMapMany( + connection -> { + Batch batch = connection.createBatch(); + for (String query : scenario.queries) { + batch.add(query); + } + return Flux.from(batch.execute()) + .flatMap(result -> result.map((row, metadata) -> "")) + .concatWith( + Mono.from(connection.close()).cast(String.class)); + }) + .blockLast(Duration.ofMinutes(1)); + })); + + if (scenario.queries.isEmpty()) { + // an empty batch fails to execute and produces a client span with no query text, summary or + // batch size; the span name falls back to the database namespace and records the error + assertThat(thrown).isInstanceOf(NoSuchElementException.class); + getTesting() + .waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> span.hasName("parent").hasKind(SpanKind.INTERNAL), + span -> + span.hasName(DB) + .hasKind(SpanKind.CLIENT) + .hasParent(trace.getSpan(0)) + .hasAttributesSatisfyingExactly( + equalTo(DB_SYSTEM_NAME, MARIADB.system), + equalTo(DB_NAMESPACE, DB), + equalTo(maybeStablePeerService(), "test-peer-service"), + equalTo(SERVER_ADDRESS, container.getHost()), + equalTo(SERVER_PORT, port), + equalTo(ERROR_TYPE, "java.util.NoSuchElementException")))); + return; + } + assertThat(thrown).isNull(); getTesting() .waitAndAssertTraces( trace -> @@ -364,6 +398,8 @@ void batchQueries(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( + // an empty batch produces no client span + new BatchScenario("empty", emptyList(), null, null, null), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix new BatchScenario("single", singletonList("SELECT 1"), "SELECT", "SELECT", null), diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index 4179e3ff9c64..175420a5bbf4 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -262,7 +262,18 @@ void batchCommand(BatchScenario scenario) throws ReflectiveOperationException { scenario.commands.accept(batch); // Adapt different method signature: // `BatchResult execute()` and `List execute()` - invokeExecute(batch); + try { + invokeExecute(batch); + } catch (ReflectiveOperationException e) { + // an empty batch fails to execute + } + + if (scenario.empty) { + // an empty batch produces no span + assertThat(testing.spans()).isEmpty(); + return; + } + testing.waitAndAssertTraces( trace -> trace.hasSpansSatisfyingExactly( @@ -292,6 +303,8 @@ void batchCommand(BatchScenario scenario) throws ReflectiveOperationException { private static Stream batchScenarios() { return Stream.of( + // an empty batch fails to execute and produces no span + BatchScenario.empty(), // a single-command batch is executed as a normal command (not a pipeline): the span is // named after the command, it carries db.operation (old) / db.operation.name (stable), // and emits no db.operation.batch.size @@ -336,6 +349,7 @@ private static final class BatchScenario { final Long batchSize; final String oldOperation; final String statement; + final boolean empty; BatchScenario( String name, @@ -352,6 +366,22 @@ private static final class BatchScenario { this.batchSize = batchSize; this.oldOperation = oldOperation; this.statement = statement; + this.empty = false; + } + + private BatchScenario() { + this.name = "empty"; + this.commands = batch -> {}; + this.spanName = null; + this.oldSpanName = null; + this.batchSize = null; + this.oldOperation = null; + this.statement = null; + this.empty = true; + } + + static BatchScenario empty() { + return new BatchScenario(); } // the stable-mode db.operation.name (= the span name in stable mode) diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java index 17abe5274588..e210344229ec 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java @@ -51,6 +51,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; @@ -291,15 +292,20 @@ private static void assertPreparedSelect() { @ParameterizedTest @MethodSource("batchScenarios") void testBatch(BatchScenario scenario) throws Exception { - testing - .runWithSpan( - "parent", - () -> - pool.preparedQuery("insert into test values ($1, $2) returning *") - .executeBatch(scenario.tuples)) - .toCompletionStage() - .toCompletableFuture() - .get(30, SECONDS); + // an empty batch is rejected before sending, so its execution fails; non-empty batches succeed + try { + testing + .runWithSpan( + "parent", + () -> + pool.preparedQuery("insert into test values ($1, $2) returning *") + .executeBatch(scenario.tuples)) + .toCompletionStage() + .toCompletableFuture() + .get(30, SECONDS); + } catch (ExecutionException e) { + // an empty batch fails to execute; the failure is recorded on the client span + } testing.waitAndAssertTraces( trace -> @@ -333,6 +339,9 @@ void testBatch(BatchScenario scenario) throws Exception { equalTo( maybeStable(DB_SQL_TABLE), emitStableDatabaseSemconv() ? null : "test"), + equalTo( + ERROR_TYPE, + emitStableDatabaseSemconv() ? scenario.errorType : null), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, host), equalTo(SERVER_PORT, port)))); @@ -340,16 +349,31 @@ void testBatch(BatchScenario scenario) throws Exception { private static Stream batchScenarios() { return Stream.of( + // an empty batch is rejected before sending, so it looks like a single statement but + // records the error and emits no db.operation.batch.size + new BatchScenario( + "empty", + Collections.emptyList(), + "insert test", + "insert test", + null, + "io.vertx.core.impl.NoStackTraceThrowable"), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix new BatchScenario( - "single", singletonList(Tuple.of(3, "Three")), "insert test", "insert test", null), + "single", + singletonList(Tuple.of(3, "Three")), + "insert test", + "insert test", + null, + null), new BatchScenario( "twoSameOperation", asList(Tuple.of(4, "Four"), Tuple.of(5, "Five")), "BATCH insert test", "BATCH insert test", - 2L)) + 2L, + null)) .map(Arguments::of); } @@ -359,18 +383,21 @@ private static final class BatchScenario { final String stableSpanName; final String stableSummary; final Long batchSize; + final String errorType; BatchScenario( String name, List tuples, String stableSpanName, String stableSummary, - Long batchSize) { + Long batchSize, + String errorType) { this.name = name; this.tuples = tuples; this.stableSpanName = stableSpanName; this.stableSummary = stableSummary; this.batchSize = batchSize; + this.errorType = errorType; } @Override diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java index fe7621c5bf63..fc708065ef2b 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java @@ -51,6 +51,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; @@ -292,15 +293,20 @@ private static void assertPreparedSelect() { @ParameterizedTest @MethodSource("batchScenarios") void testBatch(BatchScenario scenario) throws Exception { - testing - .runWithSpan( - "parent", - () -> - pool.preparedQuery("insert into test values ($1, $2) returning *") - .executeBatch(scenario.tuples)) - .toCompletionStage() - .toCompletableFuture() - .get(30, SECONDS); + // an empty batch is rejected before sending, so its execution fails; non-empty batches succeed + try { + testing + .runWithSpan( + "parent", + () -> + pool.preparedQuery("insert into test values ($1, $2) returning *") + .executeBatch(scenario.tuples)) + .toCompletionStage() + .toCompletableFuture() + .get(30, SECONDS); + } catch (ExecutionException e) { + // an empty batch fails to execute; the failure is recorded on the client span + } testing.waitAndAssertTraces( trace -> @@ -334,6 +340,9 @@ void testBatch(BatchScenario scenario) throws Exception { equalTo( maybeStable(DB_SQL_TABLE), emitStableDatabaseSemconv() ? null : "test"), + equalTo( + ERROR_TYPE, + emitStableDatabaseSemconv() ? scenario.errorType : null), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, host), equalTo(SERVER_PORT, port)))); @@ -341,16 +350,31 @@ void testBatch(BatchScenario scenario) throws Exception { private static Stream batchScenarios() { return Stream.of( + // an empty batch is rejected before sending, so it looks like a single statement but + // records the error and emits no db.operation.batch.size + new BatchScenario( + "empty", + Collections.emptyList(), + "insert test", + "insert test", + null, + "io.vertx.core.VertxException"), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix new BatchScenario( - "single", singletonList(Tuple.of(3, "Three")), "insert test", "insert test", null), + "single", + singletonList(Tuple.of(3, "Three")), + "insert test", + "insert test", + null, + null), new BatchScenario( "twoSameOperation", asList(Tuple.of(4, "Four"), Tuple.of(5, "Five")), "BATCH insert test", "BATCH insert test", - 2L)) + 2L, + null)) .map(Arguments::of); } @@ -360,18 +384,21 @@ private static final class BatchScenario { final String stableSpanName; final String stableSummary; final Long batchSize; + final String errorType; BatchScenario( String name, List tuples, String stableSpanName, String stableSummary, - Long batchSize) { + Long batchSize, + String errorType) { this.name = name; this.tuples = tuples; this.stableSpanName = stableSpanName; this.stableSummary = stableSummary; this.batchSize = batchSize; + this.errorType = errorType; } @Override From d3f76facf9c3df08f781ee87bd3a3d752885bcf2 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 13:01:42 -0700 Subject: [PATCH 06/31] spotless --- .../instrumentation/cassandra/v3_0/CassandraClientTest.java | 3 ++- .../cassandra/common/v4_0/AbstractCassandraTest.java | 3 ++- .../instrumentation/redisson/AbstractRedissonClientTest.java | 3 ++- .../vertx/sqlclient/v4_0/VertxSqlClientTest.java | 3 ++- .../vertx/sqlclient/v5_0/VertxSqlClientTest.java | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index 196f8f144071..aa260efcec93 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -297,7 +297,8 @@ void testMetrics() { } // describes the batch cases: a single-statement batch (which is executed as a normal statement, - // not a batch), two statements with the same query, and two statements with different queries. (an + // not a batch), two statements with the same query, and two statements with different queries. + // (an // empty batch is invalid CQL.) batch telemetry (db.operation.batch.size, BATCH span names and // summaries) is only emitted under stable database semconv @ParameterizedTest diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index 56f43f428a3b..b6c212cc347b 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -188,7 +188,8 @@ void simpleStatementWithValues() { } // describes the batch cases: a single-statement batch (which is executed as a normal statement, - // not a batch), two statements with the same query, and two statements with different queries. (an + // not a batch), two statements with the same query, and two statements with different queries. + // (an // empty batch is invalid CQL.) batch telemetry (db.operation.batch.size, BATCH span names and // summaries) is only emitted under stable database semconv @ParameterizedTest diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index 175420a5bbf4..a099f3ac38e2 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -278,7 +278,8 @@ void batchCommand(BatchScenario scenario) throws ReflectiveOperationException { trace -> trace.hasSpansSatisfyingExactly( span -> - span.hasName(emitStableDatabaseSemconv() ? scenario.spanName : scenario.oldSpanName) + span.hasName( + emitStableDatabaseSemconv() ? scenario.spanName : scenario.oldSpanName) .hasKind(CLIENT) .hasAttributesSatisfyingExactly( equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java index e210344229ec..1a8ac7534c5c 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java @@ -29,6 +29,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_USER; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.POSTGRESQL; import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; @@ -353,7 +354,7 @@ private static Stream batchScenarios() { // records the error and emits no db.operation.batch.size new BatchScenario( "empty", - Collections.emptyList(), + emptyList(), "insert test", "insert test", null, diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java index fc708065ef2b..ea21a1a25d00 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java @@ -29,6 +29,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_USER; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.POSTGRESQL; import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; @@ -354,7 +355,7 @@ private static Stream batchScenarios() { // records the error and emits no db.operation.batch.size new BatchScenario( "empty", - Collections.emptyList(), + emptyList(), "insert test", "insert test", null, From ccca5599d796558fd781fc5856d60dac991169ff Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 13:23:49 -0700 Subject: [PATCH 07/31] Fix NPE when instrumenting empty statement batch in JDBC javaagent --- .../javaagent/instrumentation/jdbc/JdbcAdviceScope.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/instrumentation/jdbc/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jdbc/JdbcAdviceScope.java b/instrumentation/jdbc/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jdbc/JdbcAdviceScope.java index 1685d3b5a81a..f5a3344b3e2d 100644 --- a/instrumentation/jdbc/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jdbc/JdbcAdviceScope.java +++ b/instrumentation/jdbc/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jdbc/JdbcAdviceScope.java @@ -7,6 +7,7 @@ import static io.opentelemetry.javaagent.bootstrap.Java8BytecodeBridge.currentContext; import static io.opentelemetry.javaagent.instrumentation.jdbc.JdbcSingletons.statementInstrumenter; +import static java.util.Collections.emptyList; import io.opentelemetry.context.Context; import io.opentelemetry.context.Scope; @@ -87,7 +88,7 @@ private static DbRequest createBatchRequest(Statement statement) { } else { JdbcData.StatementBatchInfo batchInfo = JdbcData.getStatementBatchInfo(statement); if (batchInfo == null) { - return DbRequest.create(statement, null); + return DbRequest.create(statement, emptyList(), null, false); } else { return DbRequest.create( statement, batchInfo.getQueryTexts(), batchInfo.getBatchSize(), false); From 8d223932b734013799210bd1386f73f4fd2ce094 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 14:31:33 -0700 Subject: [PATCH 08/31] Emit db.operation.name and db.collection.name for batch operations --- .../api/incubator/semconv/db/MultiQuery.java | 30 ++++++++- .../db/SqlClientAttributesExtractor.java | 4 ++ .../db/SqlClientAttributesExtractorTest.java | 63 +++++++++++++++++++ .../cassandra/v3_0/CassandraClientTest.java | 54 ++++++++++++---- .../common/v4_0/AbstractCassandraTest.java | 54 ++++++++++++---- 5 files changed, 181 insertions(+), 24 deletions(-) diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java index 29479d915f1b..da33c62f55a2 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java @@ -15,12 +15,20 @@ class MultiQuery { @Nullable private final String storedProcedureName; private final Set queryTexts; @Nullable private final String querySummary; + @Nullable private final String operationName; + @Nullable private final String collectionName; private MultiQuery( - @Nullable String storedProcedureName, Set queryTexts, @Nullable String querySummary) { + @Nullable String storedProcedureName, + Set queryTexts, + @Nullable String querySummary, + @Nullable String operationName, + @Nullable String collectionName) { this.storedProcedureName = storedProcedureName; this.queryTexts = queryTexts; this.querySummary = querySummary; + this.operationName = operationName; + this.collectionName = collectionName; } static MultiQuery analyzeWithSummary(Collection rawQueryTexts, SqlDialect dialect) { @@ -47,6 +55,16 @@ public String getQuerySummary() { return querySummary; } + @Nullable + public String getOperationName() { + return operationName; + } + + @Nullable + public String getCollectionName() { + return collectionName; + } + public Set getQueryTexts() { return queryTexts; } @@ -55,19 +73,27 @@ static class Builder { private final UniqueValue uniqueStoredProcedureName = new UniqueValue(); private final Set uniqueQueryTexts = new LinkedHashSet<>(); private final UniqueValue uniqueQuerySummary = new UniqueValue(); + private final UniqueValue uniqueOperationName = new UniqueValue(); + private final UniqueValue uniqueCollectionName = new UniqueValue(); + @SuppressWarnings("deprecation") // getOperationName()/getCollectionName() package-private in 3.0 void add(SqlQuery analyzedQuery, @Nullable String queryText) { uniqueStoredProcedureName.set(analyzedQuery.getStoredProcedureName()); uniqueQueryTexts.add(queryText); uniqueQuerySummary.set(analyzedQuery.getQuerySummary()); + uniqueOperationName.set(analyzedQuery.getOperationName()); + uniqueCollectionName.set(analyzedQuery.getCollectionName()); } MultiQuery build() { String querySummary = uniqueQuerySummary.getValue(); + String operationName = uniqueOperationName.getValue(); return new MultiQuery( uniqueStoredProcedureName.getValue(), uniqueQueryTexts, - querySummary == null ? "BATCH" : "BATCH " + querySummary); + querySummary == null ? "BATCH" : "BATCH " + querySummary, + operationName == null ? "BATCH" : "BATCH " + operationName, + uniqueCollectionName.getValue()); } } diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java index a995ee2e242b..0db9f091b271 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java @@ -149,6 +149,10 @@ public void onStart(AttributesBuilder attributes, Context parentContext, REQUEST MultiQuery multiQuery = builder.build(); attributes.put(DB_QUERY_TEXT, join("; ", multiQuery.getQueryTexts())); attributes.put(DB_QUERY_SUMMARY, multiQuery.getQuerySummary()); + if (singleOperationAndCollection) { + attributes.put(DB_OPERATION_NAME, multiQuery.getOperationName()); + attributes.put(DB_COLLECTION_NAME, multiQuery.getCollectionName()); + } attributes.put(DB_STORED_PROCEDURE_NAME, multiQuery.getStoredProcedureName()); } } diff --git a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java index 20723c66bc49..543101e2cdef 100644 --- a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java +++ b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java @@ -9,8 +9,10 @@ import static io.opentelemetry.instrumentation.api.internal.SemconvStability.emitOldDatabaseSemconv; import static io.opentelemetry.instrumentation.api.internal.SemconvStability.emitStableDatabaseSemconv; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.semconv.DbAttributes.DB_COLLECTION_NAME; import static io.opentelemetry.semconv.DbAttributes.DB_NAMESPACE; import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_NAME; import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_SUMMARY; import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_TEXT; import static io.opentelemetry.semconv.DbAttributes.DB_SYSTEM_NAME; @@ -412,6 +414,67 @@ void shouldExtractMixedParameterizedMultiQueryBatchAttributes() { assertThat(endAttributes.build().isEmpty()).isTrue(); } + @Test + void shouldExtractMultiQueryBatchOperationNameWhenSingleOperationAndCollection() { + // given + Map sameOperation = new HashMap<>(); + sameOperation.put("db.namespace", "potatoes"); + sameOperation.put( + "db.query.texts", asList("INSERT INTO potato VALUES(1)", "INSERT INTO potato VALUES(2)")); + sameOperation.put(DB_OPERATION_BATCH_SIZE.getKey(), 2L); + + Map mixedOperations = new HashMap<>(); + mixedOperations.put("db.namespace", "potatoes"); + mixedOperations.put( + "db.query.texts", + asList("INSERT INTO potato VALUES(1)", "UPDATE potato SET name='bob' WHERE id=1")); + mixedOperations.put(DB_OPERATION_BATCH_SIZE.getKey(), 2L); + + Map mixedCollections = new HashMap<>(); + mixedCollections.put("db.namespace", "potatoes"); + mixedCollections.put( + "db.query.texts", + asList("INSERT INTO potato VALUES(1)", "INSERT INTO tomato VALUES(2)")); + mixedCollections.put(DB_OPERATION_BATCH_SIZE.getKey(), 2L); + + Context context = Context.root(); + + AttributesExtractor, Void> underTest = + SqlClientAttributesExtractor.builder(new TestMultiAttributesGetter()) + .setSingleOperationAndCollection(true) + .build(); + + // when + AttributesBuilder sameOperationAttributes = Attributes.builder(); + underTest.onStart(sameOperationAttributes, context, sameOperation); + + AttributesBuilder mixedOperationsAttributes = Attributes.builder(); + underTest.onStart(mixedOperationsAttributes, context, mixedOperations); + + AttributesBuilder mixedCollectionsAttributes = Attributes.builder(); + underTest.onStart(mixedCollectionsAttributes, context, mixedCollections); + + // then + if (emitStableDatabaseSemconv()) { + assertThat(sameOperationAttributes.build()) + .containsEntry(DB_OPERATION_NAME, "BATCH INSERT") + .containsEntry(DB_COLLECTION_NAME, "potato") + .containsEntry(DB_QUERY_SUMMARY, "BATCH INSERT potato"); + assertThat(mixedOperationsAttributes.build()) + .containsEntry(DB_OPERATION_NAME, "BATCH") + .containsEntry(DB_COLLECTION_NAME, "potato") + .containsEntry(DB_QUERY_SUMMARY, "BATCH"); + // different collections -> db.collection.name is omitted, db.operation.name is BATCH INSERT + assertThat(mixedCollectionsAttributes.build()) + .containsEntry(DB_OPERATION_NAME, "BATCH INSERT") + .doesNotContainKey(DB_COLLECTION_NAME); + } else { + assertThat(sameOperationAttributes.build().get(DB_OPERATION_NAME)).isNull(); + assertThat(mixedOperationsAttributes.build().get(DB_OPERATION_NAME)).isNull(); + assertThat(mixedCollectionsAttributes.build().get(DB_OPERATION_NAME)).isNull(); + } + } + @Test void shouldIgnoreBatchSizeOne() { // given diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index aa260efcec93..cb7b7550a7b2 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -334,9 +334,6 @@ void batchStatement(BatchScenario scenario) { equalTo(NETWORK_PEER_ADDRESS, cassandraIp), equalTo(NETWORK_PEER_PORT, cassandraPort), equalTo(maybeStable(DB_SYSTEM), CASSANDRA), - // a single-statement batch is not a batch, so it carries the normal - // statement's db.operation and db.cassandra.table; the real batch cases - // do not equalTo( maybeStable(DB_STATEMENT), emitStableDatabaseSemconv() @@ -348,8 +345,21 @@ void batchStatement(BatchScenario scenario) { equalTo( DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? scenario.summary : null), - equalTo(maybeStable(DB_OPERATION), scenario.operation), - equalTo(maybeStable(DB_CASSANDRA_TABLE), scenario.table)))); + // under stable semconv a batch carries db.operation.name (BATCH, or + // BATCH when all statements share one operation) and + // db.collection.name (when all statements share one collection); a + // single-statement batch is not a batch, so it carries the normal + // statement's db.operation.name and db.cassandra.table + equalTo( + maybeStable(DB_OPERATION), + emitStableDatabaseSemconv() + ? scenario.operation + : scenario.oldOperation), + equalTo( + maybeStable(DB_CASSANDRA_TABLE), + emitStableDatabaseSemconv() + ? scenario.collection + : scenario.oldCollection)))); } private static Stream batchScenarios() { @@ -380,7 +390,9 @@ private static Stream batchScenarios() { .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") .summary("INSERT batch_single_test.users") .operation("INSERT") - .table("batch_single_test.users") + .oldOperation("INSERT") + .collection("batch_single_test.users") + .oldCollection("batch_single_test.users") .build(), BatchScenario.builder("twoSameOperation") .keyspace("batch_same_test") @@ -397,6 +409,8 @@ private static Stream batchScenarios() { .oldSpanName("DB Query") .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") .summary("BATCH INSERT batch_same_test.users") + .operation("BATCH INSERT batch_same_test.users") + .collection("batch_same_test.users") .batchSize(2) .build(), BatchScenario.builder("twoDifferentOperations") @@ -417,6 +431,8 @@ private static Stream batchScenarios() { .statement( "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") .summary("BATCH") + .operation("BATCH") + .collection("batch_mixed_test.users") .batchSize(2) .build()) .map(Arguments::of); @@ -433,7 +449,9 @@ private static final class BatchScenario { final String summary; final Long batchSize; final String operation; - final String table; + final String oldOperation; + final String collection; + final String oldCollection; BatchScenario(Builder builder) { this.name = builder.name; @@ -446,7 +464,9 @@ private static final class BatchScenario { this.summary = builder.summary; this.batchSize = builder.batchSize; this.operation = builder.operation; - this.table = builder.table; + this.oldOperation = builder.oldOperation; + this.collection = builder.collection; + this.oldCollection = builder.oldCollection; } @Override @@ -470,7 +490,9 @@ static final class Builder { private String summary; private Long batchSize; private String operation; - private String table; + private String oldOperation; + private String collection; + private String oldCollection; Builder(String name) { this.name = name; @@ -521,8 +543,18 @@ Builder operation(String operation) { return this; } - Builder table(String table) { - this.table = table; + Builder oldOperation(String oldOperation) { + this.oldOperation = oldOperation; + return this; + } + + Builder collection(String collection) { + this.collection = collection; + return this; + } + + Builder oldCollection(String oldCollection) { + this.oldCollection = oldCollection; return this; } diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index b6c212cc347b..0bfdf25922a2 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -243,11 +243,21 @@ void batchStatement(BatchScenario scenario) { equalTo( DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? scenario.summary : null), - // a single-statement batch is not a batch, so it carries the normal - // statement's db.operation and db.cassandra.table; the real batch - // cases do not - equalTo(maybeStable(DB_OPERATION), scenario.operation), - equalTo(maybeStable(DB_CASSANDRA_TABLE), scenario.table), + // under stable semconv a batch carries db.operation.name (BATCH, + // or BATCH when all statements share one operation) and + // db.collection.name (when all statements share one collection); a + // single-statement batch is not a batch, so it carries the normal + // statement's db.operation.name and db.cassandra.table + equalTo( + maybeStable(DB_OPERATION), + emitStableDatabaseSemconv() + ? scenario.operation + : scenario.oldOperation), + equalTo( + maybeStable(DB_CASSANDRA_TABLE), + emitStableDatabaseSemconv() + ? scenario.collection + : scenario.oldCollection), equalTo(maybeStable(DB_CASSANDRA_CONSISTENCY_LEVEL), "LOCAL_ONE"), equalTo(maybeStable(DB_CASSANDRA_COORDINATOR_DC), "datacenter1"), satisfies( @@ -290,7 +300,9 @@ private static Stream batchScenarios() { .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") .summary("INSERT batch_single_test.users") .operation("INSERT") - .table("batch_single_test.users") + .oldOperation("INSERT") + .collection("batch_single_test.users") + .oldCollection("batch_single_test.users") .build(), BatchScenario.builder("twoSameOperation") .keyspace("batch_same_test") @@ -306,6 +318,8 @@ private static Stream batchScenarios() { .oldSpanName("DB Query") .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") .summary("BATCH INSERT batch_same_test.users") + .operation("BATCH INSERT batch_same_test.users") + .collection("batch_same_test.users") .batchSize(2) .build(), BatchScenario.builder("twoDifferentOperations") @@ -326,6 +340,8 @@ private static Stream batchScenarios() { .statement( "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") .summary("BATCH") + .operation("BATCH") + .collection("batch_mixed_test.users") .batchSize(2) .build()) .map(Arguments::of); @@ -342,7 +358,9 @@ private static final class BatchScenario { final String summary; final Long batchSize; final String operation; - final String table; + final String oldOperation; + final String collection; + final String oldCollection; BatchScenario(Builder builder) { this.name = builder.name; @@ -355,7 +373,9 @@ private static final class BatchScenario { this.summary = builder.summary; this.batchSize = builder.batchSize; this.operation = builder.operation; - this.table = builder.table; + this.oldOperation = builder.oldOperation; + this.collection = builder.collection; + this.oldCollection = builder.oldCollection; } @Override @@ -379,7 +399,9 @@ static final class Builder { private String summary; private Long batchSize; private String operation; - private String table; + private String oldOperation; + private String collection; + private String oldCollection; Builder(String name) { this.name = name; @@ -430,8 +452,18 @@ Builder operation(String operation) { return this; } - Builder table(String table) { - this.table = table; + Builder oldOperation(String oldOperation) { + this.oldOperation = oldOperation; + return this; + } + + Builder collection(String collection) { + this.collection = collection; + return this; + } + + Builder oldCollection(String oldCollection) { + this.oldCollection = oldCollection; return this; } From b5c86a86bfaf6717a1810e60d0db7f93a11e8171 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 14:31:39 -0700 Subject: [PATCH 09/31] Make JDBC and R2DBC batch tests dual-mode --- .../AbstractJdbcInstrumentationTest.java | 95 +++++++++++-- .../v1_0/AbstractR2dbcStatementTest.java | 130 ++++++++++++++---- 2 files changed, 187 insertions(+), 38 deletions(-) diff --git a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java index 3a645c0d89e4..72b944ca4830 100644 --- a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java +++ b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java @@ -18,7 +18,6 @@ import static io.opentelemetry.semconv.DbAttributes.DB_NAMESPACE; import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_SUMMARY; -import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_TEXT; import static io.opentelemetry.semconv.DbAttributes.DB_STORED_PROCEDURE_NAME; import static io.opentelemetry.semconv.DbAttributes.DB_SYSTEM_NAME; import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; @@ -1887,18 +1886,30 @@ void testProxyPreparedStatement() throws SQLException { static Stream batchCasesStream() { return Stream.of( // an empty batch still produces a client span, but with no query text or batch size; - // the span name falls back to the database namespace - BatchScenario.builder().name("empty").spanName(DATABASE_NAME_LOWER).build(), - // a single-statement batch is not a batch (size 1), so it looks like a normal statement + // the span name falls back to the database namespace in both modes + BatchScenario.builder() + .name("empty") + .spanName(DATABASE_NAME_LOWER) + .oldSpanName(DATABASE_NAME_LOWER) + .build(), + // a single-statement batch is not a batch (size 1), so it looks like a normal + // statement and carries db.statement/db.operation/db.sql.table under old semconv BatchScenario.builder() .name("single") .createTable("stmt_batch_single") .addQuery("INSERT INTO stmt_batch_single VALUES(1)") .expectedResult(1) .spanName("INSERT stmt_batch_single") + .oldSpanName("INSERT " + DATABASE_NAME_LOWER + ".stmt_batch_single") .queryText("INSERT INTO stmt_batch_single VALUES(?)") + .oldStatement("INSERT INTO stmt_batch_single VALUES(?)") .summary("INSERT stmt_batch_single") + .oldOperation("INSERT") + .oldTable("stmt_batch_single") .build(), + // a multi-statement batch only emits db.query.text/summary and BATCH span name under + // stable semconv; under old semconv it has no statement-level attributes and the span + // name falls back to the namespace BatchScenario.builder() .name("twoSameOperation") .createTable("stmt_batch_same") @@ -1906,6 +1917,7 @@ static Stream batchCasesStream() { .addQuery("INSERT INTO stmt_batch_same VALUES(2)") .expectedResult(1, 1) .spanName("BATCH INSERT stmt_batch_same") + .oldSpanName(DATABASE_NAME_LOWER) .queryText("INSERT INTO stmt_batch_same VALUES(?)") .summary("BATCH INSERT stmt_batch_same") .batchSize(2) @@ -1918,6 +1930,7 @@ static Stream batchCasesStream() { .addQuery("INSERT INTO stmt_batch_diff_2 VALUES(2)") .expectedResult(1, 1) .spanName("BATCH") + .oldSpanName(DATABASE_NAME_LOWER) .queryText( "INSERT INTO stmt_batch_diff_1 VALUES(?); INSERT INTO stmt_batch_diff_2" + " VALUES(?)") @@ -1930,10 +1943,6 @@ static Stream batchCasesStream() { @ParameterizedTest @MethodSource("batchCasesStream") void testStatementBatch(BatchScenario scenario) throws SQLException { - // batch telemetry (db.operation.batch.size, BATCH span names and summaries) is only emitted - // under stable database semconv - Assumptions.assumeTrue(emitStableDatabaseSemconv()); - Connection connection = wrap(new org.h2.Driver().connect(JDBC_URLS.get("h2"), null)); cleanup.deferCleanup(connection); @@ -1964,15 +1973,41 @@ void testStatementBatch(BatchScenario scenario) throws SQLException { trace.hasSpansSatisfyingExactly( span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(), span -> - span.hasName(scenario.spanName) + span.hasName( + emitStableDatabaseSemconv() + ? scenario.spanName + : scenario.oldSpanName) .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( - equalTo(DB_SYSTEM_NAME, maybeStableDbSystemName("h2")), - equalTo(DB_NAMESPACE, DATABASE_NAME_LOWER), - equalTo(DB_QUERY_TEXT, scenario.queryText), - equalTo(DB_QUERY_SUMMARY, scenario.summary), - equalTo(DB_OPERATION_BATCH_SIZE, scenario.batchSize)))); + equalTo(maybeStable(DB_SYSTEM), maybeStableDbSystemName("h2")), + equalTo(maybeStable(DB_NAME), DATABASE_NAME_LOWER), + equalTo( + DB_CONNECTION_STRING, + emitStableDatabaseSemconv() ? null : "h2:mem:"), + // batch telemetry (db.query.summary, BATCH span names and + // db.operation.batch.size) is only emitted under stable semconv; + // under old semconv the statement-level attributes (db.statement, + // db.operation, db.sql.table) are only set for a single-statement + // batch and multi-statement batches fall back to the namespace span + // name + equalTo( + maybeStable(DB_STATEMENT), + emitStableDatabaseSemconv() + ? scenario.queryText + : scenario.oldStatement), + equalTo( + DB_QUERY_SUMMARY, + emitStableDatabaseSemconv() ? scenario.summary : null), + equalTo( + maybeStable(DB_OPERATION), + emitStableDatabaseSemconv() ? null : scenario.oldOperation), + equalTo( + maybeStable(DB_SQL_TABLE), + emitStableDatabaseSemconv() ? null : scenario.oldTable), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? scenario.batchSize : null)))); } static Stream batchStream() throws SQLException { @@ -2372,8 +2407,12 @@ private static final class BatchScenario { final List statementsToAdd; final int[] expectedResult; final String spanName; + final String oldSpanName; final String queryText; + final String oldStatement; final String summary; + final String oldOperation; + final String oldTable; final Long batchSize; BatchScenario(Builder builder) { @@ -2382,8 +2421,12 @@ private static final class BatchScenario { this.statementsToAdd = builder.statementsToAdd; this.expectedResult = builder.expectedResult; this.spanName = builder.spanName; + this.oldSpanName = builder.oldSpanName; this.queryText = builder.queryText; + this.oldStatement = builder.oldStatement; this.summary = builder.summary; + this.oldOperation = builder.oldOperation; + this.oldTable = builder.oldTable; this.batchSize = builder.batchSize; } @@ -2403,8 +2446,12 @@ static final class Builder { private final List statementsToAdd = new ArrayList<>(); private int[] expectedResult = new int[] {}; private String spanName; + private String oldSpanName; private String queryText; + private String oldStatement; private String summary; + private String oldOperation; + private String oldTable; private Long batchSize; Builder name(String name) { @@ -2432,16 +2479,36 @@ Builder spanName(String spanName) { return this; } + Builder oldSpanName(String oldSpanName) { + this.oldSpanName = oldSpanName; + return this; + } + Builder queryText(String queryText) { this.queryText = queryText; return this; } + Builder oldStatement(String oldStatement) { + this.oldStatement = oldStatement; + return this; + } + Builder summary(String summary) { this.summary = summary; return this; } + Builder oldOperation(String oldOperation) { + this.oldOperation = oldOperation; + return this; + } + + Builder oldTable(String oldTable) { + this.oldTable = oldTable; + return this; + } + Builder batchSize(long batchSize) { this.batchSize = batchSize; return this; diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index fd51de6c5a6b..54c2a03e2b53 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -14,7 +14,6 @@ import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_NAME; import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_SUMMARY; -import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_TEXT; import static io.opentelemetry.semconv.DbAttributes.DB_SYSTEM_NAME; import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; @@ -38,7 +37,6 @@ import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; -import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.junit.jupiter.api.Named.named; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -307,15 +305,15 @@ void testMetrics() { SERVER_PORT); } - // describes the batch cases: an empty batch (no statements -> no client span), a single-statement - // batch (not a batch -> no db.operation.batch.size) and two statements with the same operation. - // batch telemetry (db.operation.batch.size, BATCH span names and summaries) is only emitted under - // stable database semconv + // describes the batch cases: an empty batch (no statements -> error client span), a + // single-statement batch (not a batch -> no db.operation.batch.size) and two statements with the + // same operation. batch telemetry (db.operation.batch.size, BATCH span names and summaries) is + // only emitted under stable database semconv; old semconv only sets statement-level attributes + // (db.statement, db.operation, db.sql.table) for a single-statement batch + @SuppressWarnings("deprecation") // using deprecated semconv @ParameterizedTest @MethodSource("batchScenarios") void batchQueries(BatchScenario scenario) { - assumeTrue(emitStableDatabaseSemconv()); - DbSystemProps props = systems.get(MARIADB.system); startContainer(props); ConnectionFactory connectionFactory = @@ -352,9 +350,13 @@ void batchQueries(BatchScenario scenario) { .blockLast(Duration.ofMinutes(1)); })); + String connectionString = MARIADB.system + "://localhost:" + port; + if (scenario.queries.isEmpty()) { - // an empty batch fails to execute and produces a client span with no query text, summary or - // batch size; the span name falls back to the database namespace and records the error + // an empty batch fails to execute and produces a client span with no operation, summary or + // batch size; the span name falls back to the database namespace. under old semconv it still + // carries db.user/db.connection_string and an empty db.statement; under stable semconv it + // records the error instead (error.type is stable-only) assertThat(thrown).isInstanceOf(NoSuchElementException.class); getTesting() .waitAndAssertTraces( @@ -366,12 +368,23 @@ void batchQueries(BatchScenario scenario) { .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( - equalTo(DB_SYSTEM_NAME, MARIADB.system), - equalTo(DB_NAMESPACE, DB), + equalTo( + DB_CONNECTION_STRING, + emitStableDatabaseSemconv() ? null : connectionString), + equalTo(maybeStable(DB_SYSTEM), MARIADB.system), + equalTo(maybeStable(DB_NAME), DB), + equalTo(DB_USER, emitStableDatabaseSemconv() ? null : USER_DB), + equalTo( + maybeStable(DB_STATEMENT), + emitStableDatabaseSemconv() ? null : ""), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, container.getHost()), equalTo(SERVER_PORT, port), - equalTo(ERROR_TYPE, "java.util.NoSuchElementException")))); + equalTo( + ERROR_TYPE, + emitStableDatabaseSemconv() + ? "java.util.NoSuchElementException" + : null)))); return; } @@ -382,15 +395,36 @@ void batchQueries(BatchScenario scenario) { trace.hasSpansSatisfyingExactly( span -> span.hasName("parent").hasKind(SpanKind.INTERNAL), span -> - span.hasName(scenario.spanName) + span.hasName( + emitStableDatabaseSemconv() + ? scenario.spanName + : scenario.oldSpanName) .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( - equalTo(DB_SYSTEM_NAME, MARIADB.system), - equalTo(DB_NAMESPACE, DB), - equalTo(DB_QUERY_TEXT, "SELECT ?"), - equalTo(DB_QUERY_SUMMARY, scenario.summary), - equalTo(DB_OPERATION_BATCH_SIZE, scenario.batchSize), + equalTo( + DB_CONNECTION_STRING, + emitStableDatabaseSemconv() ? null : connectionString), + equalTo(maybeStable(DB_SYSTEM), MARIADB.system), + equalTo(maybeStable(DB_NAME), DB), + equalTo(DB_USER, emitStableDatabaseSemconv() ? null : USER_DB), + // maybeStable(DB_STATEMENT) is db.query.text under stable semconv + // (identical query texts are deduplicated) and db.statement under + // old semconv (individual texts are concatenated with "; ") + equalTo( + maybeStable(DB_STATEMENT), + emitStableDatabaseSemconv() + ? scenario.queryText + : scenario.oldStatement), + equalTo( + DB_QUERY_SUMMARY, + emitStableDatabaseSemconv() ? scenario.summary : null), + equalTo( + maybeStable(DB_OPERATION), + emitStableDatabaseSemconv() ? null : scenario.oldOperation), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, container.getHost()), equalTo(SERVER_PORT, port)))); @@ -398,16 +432,32 @@ void batchQueries(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( - // an empty batch produces no client span - new BatchScenario("empty", emptyList(), null, null, null), + // an empty batch produces an error client span + new BatchScenario("empty", emptyList(), null, null, null, null, null, null), // a single-statement batch is not a batch (size 1), so it emits no - // db.operation.batch.size and no BATCH prefix - new BatchScenario("single", singletonList("SELECT 1"), "SELECT", "SELECT", null), + // db.operation.batch.size and no BATCH prefix; under old semconv it carries + // db.statement/db.operation and the operation+namespace span name + new BatchScenario( + "single", + singletonList("SELECT 1"), + "SELECT", + "SELECT " + DB, + "SELECT", + "SELECT ?", + "SELECT ?", + "SELECT"), + // a multi-statement batch emits the BATCH span name, deduplicated db.query.text and + // db.operation.batch.size under stable semconv; under old semconv the individual + // statements are concatenated and the span name is operation+namespace new BatchScenario( "twoSameOperation", asList("SELECT 1", "SELECT 2"), "BATCH SELECT", + "SELECT " + DB, "BATCH SELECT", + "SELECT ?", + "SELECT ?; SELECT ?", + "SELECT", 2L)) .map(Arguments::of); } @@ -416,15 +466,47 @@ private static final class BatchScenario { final String name; final List queries; final String spanName; + final String oldSpanName; final String summary; + // the stable-semconv db.query.text (identical query texts are deduplicated) + final String queryText; + // the old-semconv db.statement (individual query texts concatenated with "; ") + final String oldStatement; + final String oldOperation; final Long batchSize; + // empty-batch scenario (no stable batch attributes, handled separately in the test) + BatchScenario( + String name, + List queries, + String spanName, + String oldSpanName, + String summary, + String queryText, + String oldStatement, + String oldOperation) { + this(name, queries, spanName, oldSpanName, summary, queryText, oldStatement, oldOperation, + null); + } + BatchScenario( - String name, List queries, String spanName, String summary, Long batchSize) { + String name, + List queries, + String spanName, + String oldSpanName, + String summary, + String queryText, + String oldStatement, + String oldOperation, + Long batchSize) { this.name = name; this.queries = queries; this.spanName = spanName; + this.oldSpanName = oldSpanName; this.summary = summary; + this.queryText = queryText; + this.oldStatement = oldStatement; + this.oldOperation = oldOperation; this.batchSize = batchSize; } From f644057dc717364c06c4d8e46d6023ba82a0491d Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 14:45:08 -0700 Subject: [PATCH 10/31] Move batch test BatchScenario classes to file bottom and use builder pattern --- .../v2_2/AbstractAws2ClientCoreTest.java | 48 ++-- .../cassandra/v3_0/CassandraClientTest.java | 252 +++++++++--------- .../common/v4_0/AbstractCassandraTest.java | 252 +++++++++--------- .../v1_0/AbstractR2dbcStatementTest.java | 191 +++++++------ .../redisson/AbstractRedissonClientTest.java | 207 ++++++++------ .../sqlclient/v4_0/VertxSqlClientTest.java | 138 ++++++---- .../sqlclient/v5_0/VertxSqlClientTest.java | 138 ++++++---- 7 files changed, 687 insertions(+), 539 deletions(-) diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java index f15856104e52..71f9c1dc1bfb 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java @@ -708,6 +708,30 @@ private static Stream batchScenarios() { .map(Arguments::of); } + private static String getResponseContent(String operation) { + switch (operation) { + case "ListTables": + return "{\"TableNames\":[\"sometable\"]}"; + case "BatchGetItem": + return "{\"ConsumedCapacity\":[{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}]}"; + case "GetItem": + case "Query": + case "UpdateTable": + return "{\"ConsumedCapacity\":{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}}"; + case "BatchWriteItem": + return "{\"ConsumedCapacity\":[{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}],\"ItemCollectionMetrics\":{\"somekey1\":[{\"ItemCollectionKey\":{\"somekey2\":{}}}]}}"; + case "CreateTable": + case "DeleteItem": + case "PutItem": + case "UpdateItem": + return "{\"ConsumedCapacity\":{\"TableName\":\"sometable\",\"CapacityUnits\":1.0},\"ItemCollectionMetrics\":{\"ItemCollectionKey\":{\"somekey\":{}}}}"; + case "Scan": + return "{\"Count\":1,\"ScannedCount\":1,\"ConsumedCapacity\":{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}}"; + default: + return ""; + } + } + private static final class BatchScenario { final String name; final String awsOperation; @@ -825,28 +849,4 @@ BatchScenario build() { } } } - - private static String getResponseContent(String operation) { - switch (operation) { - case "ListTables": - return "{\"TableNames\":[\"sometable\"]}"; - case "BatchGetItem": - return "{\"ConsumedCapacity\":[{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}]}"; - case "GetItem": - case "Query": - case "UpdateTable": - return "{\"ConsumedCapacity\":{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}}"; - case "BatchWriteItem": - return "{\"ConsumedCapacity\":[{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}],\"ItemCollectionMetrics\":{\"somekey1\":[{\"ItemCollectionKey\":{\"somekey2\":{}}}]}}"; - case "CreateTable": - case "DeleteItem": - case "PutItem": - case "UpdateItem": - return "{\"ConsumedCapacity\":{\"TableName\":\"sometable\",\"CapacityUnits\":1.0},\"ItemCollectionMetrics\":{\"ItemCollectionKey\":{\"somekey\":{}}}}"; - case "Scan": - return "{\"Count\":1,\"ScannedCount\":1,\"ConsumedCapacity\":{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}}"; - default: - return ""; - } - } } diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index cb7b7550a7b2..7582dda1a1b3 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -438,132 +438,6 @@ private static Stream batchScenarios() { .map(Arguments::of); } - private static final class BatchScenario { - final String name; - final String keyspace; - final Function buildBatch; - final String spanName; - final String oldSpanName; - final String statement; - final String oldStatement; - final String summary; - final Long batchSize; - final String operation; - final String oldOperation; - final String collection; - final String oldCollection; - - BatchScenario(Builder builder) { - this.name = builder.name; - this.keyspace = builder.keyspace; - this.buildBatch = builder.buildBatch; - this.spanName = builder.spanName; - this.oldSpanName = builder.oldSpanName; - this.statement = builder.statement; - this.oldStatement = builder.oldStatement; - this.summary = builder.summary; - this.batchSize = builder.batchSize; - this.operation = builder.operation; - this.oldOperation = builder.oldOperation; - this.collection = builder.collection; - this.oldCollection = builder.oldCollection; - } - - @Override - public String toString() { - // used as the parameterized test display name - return name; - } - - static Builder builder(String name) { - return new Builder(name); - } - - static final class Builder { - private final String name; - private String keyspace; - private Function buildBatch; - private String spanName; - private String oldSpanName; - private String statement; - private String oldStatement; - private String summary; - private Long batchSize; - private String operation; - private String oldOperation; - private String collection; - private String oldCollection; - - Builder(String name) { - this.name = name; - } - - Builder keyspace(String keyspace) { - this.keyspace = keyspace; - return this; - } - - Builder buildBatch(Function buildBatch) { - this.buildBatch = buildBatch; - return this; - } - - Builder spanName(String spanName) { - this.spanName = spanName; - return this; - } - - Builder oldSpanName(String oldSpanName) { - this.oldSpanName = oldSpanName; - return this; - } - - Builder statement(String statement) { - this.statement = statement; - return this; - } - - Builder oldStatement(String oldStatement) { - this.oldStatement = oldStatement; - return this; - } - - Builder summary(String summary) { - this.summary = summary; - return this; - } - - Builder batchSize(long batchSize) { - this.batchSize = batchSize; - return this; - } - - Builder operation(String operation) { - this.operation = operation; - return this; - } - - Builder oldOperation(String oldOperation) { - this.oldOperation = oldOperation; - return this; - } - - Builder collection(String collection) { - this.collection = collection; - return this; - } - - Builder oldCollection(String oldCollection) { - this.oldCollection = oldCollection; - return this; - } - - BatchScenario build() { - return new BatchScenario(this); - } - } - } - private static Stream provideSyncParameters() { return Stream.of( Arguments.of( @@ -695,4 +569,130 @@ private static class Parameter { this.table = table; } } + + private static final class BatchScenario { + final String name; + final String keyspace; + final Function buildBatch; + final String spanName; + final String oldSpanName; + final String statement; + final String oldStatement; + final String summary; + final Long batchSize; + final String operation; + final String oldOperation; + final String collection; + final String oldCollection; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.keyspace = builder.keyspace; + this.buildBatch = builder.buildBatch; + this.spanName = builder.spanName; + this.oldSpanName = builder.oldSpanName; + this.statement = builder.statement; + this.oldStatement = builder.oldStatement; + this.summary = builder.summary; + this.batchSize = builder.batchSize; + this.operation = builder.operation; + this.oldOperation = builder.oldOperation; + this.collection = builder.collection; + this.oldCollection = builder.oldCollection; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static Builder builder(String name) { + return new Builder(name); + } + + static final class Builder { + private final String name; + private String keyspace; + private Function buildBatch; + private String spanName; + private String oldSpanName; + private String statement; + private String oldStatement; + private String summary; + private Long batchSize; + private String operation; + private String oldOperation; + private String collection; + private String oldCollection; + + Builder(String name) { + this.name = name; + } + + Builder keyspace(String keyspace) { + this.keyspace = keyspace; + return this; + } + + Builder buildBatch(Function buildBatch) { + this.buildBatch = buildBatch; + return this; + } + + Builder spanName(String spanName) { + this.spanName = spanName; + return this; + } + + Builder oldSpanName(String oldSpanName) { + this.oldSpanName = oldSpanName; + return this; + } + + Builder statement(String statement) { + this.statement = statement; + return this; + } + + Builder oldStatement(String oldStatement) { + this.oldStatement = oldStatement; + return this; + } + + Builder summary(String summary) { + this.summary = summary; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + Builder operation(String operation) { + this.operation = operation; + return this; + } + + Builder oldOperation(String oldOperation) { + this.oldOperation = oldOperation; + return this; + } + + Builder collection(String collection) { + this.collection = collection; + return this; + } + + Builder oldCollection(String oldCollection) { + this.oldCollection = oldCollection; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index 0bfdf25922a2..b6cabbeb863c 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -347,132 +347,6 @@ private static Stream batchScenarios() { .map(Arguments::of); } - private static final class BatchScenario { - final String name; - final String keyspace; - final Function buildBatch; - final String spanName; - final String oldSpanName; - final String statement; - final String oldStatement; - final String summary; - final Long batchSize; - final String operation; - final String oldOperation; - final String collection; - final String oldCollection; - - BatchScenario(Builder builder) { - this.name = builder.name; - this.keyspace = builder.keyspace; - this.buildBatch = builder.buildBatch; - this.spanName = builder.spanName; - this.oldSpanName = builder.oldSpanName; - this.statement = builder.statement; - this.oldStatement = builder.oldStatement; - this.summary = builder.summary; - this.batchSize = builder.batchSize; - this.operation = builder.operation; - this.oldOperation = builder.oldOperation; - this.collection = builder.collection; - this.oldCollection = builder.oldCollection; - } - - @Override - public String toString() { - // used as the parameterized test display name - return name; - } - - static Builder builder(String name) { - return new Builder(name); - } - - static final class Builder { - private final String name; - private String keyspace; - private Function buildBatch; - private String spanName; - private String oldSpanName; - private String statement; - private String oldStatement; - private String summary; - private Long batchSize; - private String operation; - private String oldOperation; - private String collection; - private String oldCollection; - - Builder(String name) { - this.name = name; - } - - Builder keyspace(String keyspace) { - this.keyspace = keyspace; - return this; - } - - Builder buildBatch(Function buildBatch) { - this.buildBatch = buildBatch; - return this; - } - - Builder spanName(String spanName) { - this.spanName = spanName; - return this; - } - - Builder oldSpanName(String oldSpanName) { - this.oldSpanName = oldSpanName; - return this; - } - - Builder statement(String statement) { - this.statement = statement; - return this; - } - - Builder oldStatement(String oldStatement) { - this.oldStatement = oldStatement; - return this; - } - - Builder summary(String summary) { - this.summary = summary; - return this; - } - - Builder batchSize(long batchSize) { - this.batchSize = batchSize; - return this; - } - - Builder operation(String operation) { - this.operation = operation; - return this; - } - - Builder oldOperation(String oldOperation) { - this.oldOperation = oldOperation; - return this; - } - - Builder collection(String collection) { - this.collection = collection; - return this; - } - - Builder oldCollection(String oldCollection) { - this.oldCollection = oldCollection; - return this; - } - - BatchScenario build() { - return new BatchScenario(this); - } - } - } - @ParameterizedTest(name = "{index}: {0}") @MethodSource("provideSyncParameters") void syncTest(Parameter parameter) { @@ -728,4 +602,130 @@ protected CqlSessionBuilder addContactPoint(CqlSessionBuilder sessionBuilder) { sessionBuilder.addContactPoint(new InetSocketAddress(cassandra.getHost(), cassandraPort)); return sessionBuilder; } + + private static final class BatchScenario { + final String name; + final String keyspace; + final Function buildBatch; + final String spanName; + final String oldSpanName; + final String statement; + final String oldStatement; + final String summary; + final Long batchSize; + final String operation; + final String oldOperation; + final String collection; + final String oldCollection; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.keyspace = builder.keyspace; + this.buildBatch = builder.buildBatch; + this.spanName = builder.spanName; + this.oldSpanName = builder.oldSpanName; + this.statement = builder.statement; + this.oldStatement = builder.oldStatement; + this.summary = builder.summary; + this.batchSize = builder.batchSize; + this.operation = builder.operation; + this.oldOperation = builder.oldOperation; + this.collection = builder.collection; + this.oldCollection = builder.oldCollection; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static Builder builder(String name) { + return new Builder(name); + } + + static final class Builder { + private final String name; + private String keyspace; + private Function buildBatch; + private String spanName; + private String oldSpanName; + private String statement; + private String oldStatement; + private String summary; + private Long batchSize; + private String operation; + private String oldOperation; + private String collection; + private String oldCollection; + + Builder(String name) { + this.name = name; + } + + Builder keyspace(String keyspace) { + this.keyspace = keyspace; + return this; + } + + Builder buildBatch(Function buildBatch) { + this.buildBatch = buildBatch; + return this; + } + + Builder spanName(String spanName) { + this.spanName = spanName; + return this; + } + + Builder oldSpanName(String oldSpanName) { + this.oldSpanName = oldSpanName; + return this; + } + + Builder statement(String statement) { + this.statement = statement; + return this; + } + + Builder oldStatement(String oldStatement) { + this.oldStatement = oldStatement; + return this; + } + + Builder summary(String summary) { + this.summary = summary; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + Builder operation(String operation) { + this.operation = operation; + return this; + } + + Builder oldOperation(String oldOperation) { + this.oldOperation = oldOperation; + return this; + } + + Builder collection(String collection) { + this.collection = collection; + return this; + } + + Builder oldCollection(String oldCollection) { + this.oldCollection = oldCollection; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index 54c2a03e2b53..e0294637b813 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -433,90 +433,35 @@ void batchQueries(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( // an empty batch produces an error client span - new BatchScenario("empty", emptyList(), null, null, null, null, null, null), + BatchScenario.builder("empty").queries(emptyList()).build(), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix; under old semconv it carries // db.statement/db.operation and the operation+namespace span name - new BatchScenario( - "single", - singletonList("SELECT 1"), - "SELECT", - "SELECT " + DB, - "SELECT", - "SELECT ?", - "SELECT ?", - "SELECT"), + BatchScenario.builder("single") + .queries(singletonList("SELECT 1")) + .spanName("SELECT") + .oldSpanName("SELECT " + DB) + .summary("SELECT") + .queryText("SELECT ?") + .oldStatement("SELECT ?") + .oldOperation("SELECT") + .build(), // a multi-statement batch emits the BATCH span name, deduplicated db.query.text and // db.operation.batch.size under stable semconv; under old semconv the individual // statements are concatenated and the span name is operation+namespace - new BatchScenario( - "twoSameOperation", - asList("SELECT 1", "SELECT 2"), - "BATCH SELECT", - "SELECT " + DB, - "BATCH SELECT", - "SELECT ?", - "SELECT ?; SELECT ?", - "SELECT", - 2L)) + BatchScenario.builder("twoSameOperation") + .queries(asList("SELECT 1", "SELECT 2")) + .spanName("BATCH SELECT") + .oldSpanName("SELECT " + DB) + .summary("BATCH SELECT") + .queryText("SELECT ?") + .oldStatement("SELECT ?; SELECT ?") + .oldOperation("SELECT") + .batchSize(2) + .build()) .map(Arguments::of); } - private static final class BatchScenario { - final String name; - final List queries; - final String spanName; - final String oldSpanName; - final String summary; - // the stable-semconv db.query.text (identical query texts are deduplicated) - final String queryText; - // the old-semconv db.statement (individual query texts concatenated with "; ") - final String oldStatement; - final String oldOperation; - final Long batchSize; - - // empty-batch scenario (no stable batch attributes, handled separately in the test) - BatchScenario( - String name, - List queries, - String spanName, - String oldSpanName, - String summary, - String queryText, - String oldStatement, - String oldOperation) { - this(name, queries, spanName, oldSpanName, summary, queryText, oldStatement, oldOperation, - null); - } - - BatchScenario( - String name, - List queries, - String spanName, - String oldSpanName, - String summary, - String queryText, - String oldStatement, - String oldOperation, - Long batchSize) { - this.name = name; - this.queries = queries; - this.spanName = spanName; - this.oldSpanName = oldSpanName; - this.summary = summary; - this.queryText = queryText; - this.oldStatement = oldStatement; - this.oldOperation = oldOperation; - this.batchSize = batchSize; - } - - @Override - public String toString() { - // used as the parameterized test display name - return name; - } - } - private static class Parameter { private final String system; @@ -570,4 +515,100 @@ private DbSystemProps envVariables(String... keyValues) { return this; } } + + private static final class BatchScenario { + final String name; + final List queries; + final String spanName; + final String oldSpanName; + final String summary; + // the stable-semconv db.query.text (identical query texts are deduplicated) + final String queryText; + // the old-semconv db.statement (individual query texts concatenated with "; ") + final String oldStatement; + final String oldOperation; + final Long batchSize; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.queries = builder.queries; + this.spanName = builder.spanName; + this.oldSpanName = builder.oldSpanName; + this.summary = builder.summary; + this.queryText = builder.queryText; + this.oldStatement = builder.oldStatement; + this.oldOperation = builder.oldOperation; + this.batchSize = builder.batchSize; + } + + static Builder builder(String name) { + return new Builder(name); + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static final class Builder { + private final String name; + private List queries; + private String spanName; + private String oldSpanName; + private String summary; + private String queryText; + private String oldStatement; + private String oldOperation; + private Long batchSize; + + Builder(String name) { + this.name = name; + } + + Builder queries(List queries) { + this.queries = queries; + return this; + } + + Builder spanName(String spanName) { + this.spanName = spanName; + return this; + } + + Builder oldSpanName(String oldSpanName) { + this.oldSpanName = oldSpanName; + return this; + } + + Builder summary(String summary) { + this.summary = summary; + return this; + } + + Builder queryText(String queryText) { + this.queryText = queryText; + return this; + } + + Builder oldStatement(String oldStatement) { + this.oldStatement = oldStatement; + return this; + } + + Builder oldOperation(String oldOperation) { + this.oldOperation = oldOperation; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index a099f3ac38e2..f9751cccf5f5 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -305,98 +305,42 @@ void batchCommand(BatchScenario scenario) throws ReflectiveOperationException { private static Stream batchScenarios() { return Stream.of( // an empty batch fails to execute and produces no span - BatchScenario.empty(), + BatchScenario.builder("empty").commands(batch -> {}).empty().build(), // a single-command batch is executed as a normal command (not a pipeline): the span is // named after the command, it carries db.operation (old) / db.operation.name (stable), // and emits no db.operation.batch.size - new BatchScenario( - "single", - batch -> batch.getBucket("batch1").setAsync("v1"), - "SET", - "SET", - null, - "SET", - "SET batch1 ?"), - new BatchScenario( - "twoSameOperation", - batch -> { - batch.getBucket("batch1").setAsync("v1"); - batch.getBucket("batch2").setAsync("v2"); - }, - "PIPELINE SET", - "DB Query", - 2L, - null, - "SET batch1 ?;SET batch2 ?"), - new BatchScenario( - "twoDifferentOperations", - batch -> { - batch.getBucket("batch1").setAsync("v1"); - batch.getBucket("batch1").getAsync(); - }, - "PIPELINE", - "DB Query", - 2L, - null, - "SET batch1 ?;GET batch1")) + BatchScenario.builder("single") + .commands(batch -> batch.getBucket("batch1").setAsync("v1")) + .spanName("SET") + .oldSpanName("SET") + .oldOperation("SET") + .statement("SET batch1 ?") + .build(), + BatchScenario.builder("twoSameOperation") + .commands( + batch -> { + batch.getBucket("batch1").setAsync("v1"); + batch.getBucket("batch2").setAsync("v2"); + }) + .spanName("PIPELINE SET") + .oldSpanName("DB Query") + .batchSize(2) + .statement("SET batch1 ?;SET batch2 ?") + .build(), + BatchScenario.builder("twoDifferentOperations") + .commands( + batch -> { + batch.getBucket("batch1").setAsync("v1"); + batch.getBucket("batch1").getAsync(); + }) + .spanName("PIPELINE") + .oldSpanName("DB Query") + .batchSize(2) + .statement("SET batch1 ?;GET batch1") + .build()) .map(Arguments::of); } - private static final class BatchScenario { - final String name; - final Consumer commands; - final String spanName; - final String oldSpanName; - final Long batchSize; - final String oldOperation; - final String statement; - final boolean empty; - - BatchScenario( - String name, - Consumer commands, - String spanName, - String oldSpanName, - Long batchSize, - String oldOperation, - String statement) { - this.name = name; - this.commands = commands; - this.spanName = spanName; - this.oldSpanName = oldSpanName; - this.batchSize = batchSize; - this.oldOperation = oldOperation; - this.statement = statement; - this.empty = false; - } - - private BatchScenario() { - this.name = "empty"; - this.commands = batch -> {}; - this.spanName = null; - this.oldSpanName = null; - this.batchSize = null; - this.oldOperation = null; - this.statement = null; - this.empty = true; - } - - static BatchScenario empty() { - return new BatchScenario(); - } - - // the stable-mode db.operation.name (= the span name in stable mode) - String operationName() { - return spanName; - } - - @Override - public String toString() { - // used as the parameterized test display name - return name; - } - } - private static void invokeExecute(RBatch batch) throws ReflectiveOperationException { batch.getClass().getMethod("execute").invoke(batch); } @@ -703,4 +647,95 @@ protected boolean lockHas3Traces() { protected RBatch createBatch(RedissonClient redisson) { return redisson.createBatch(BatchOptions.defaults()); } + + private static final class BatchScenario { + final String name; + final Consumer commands; + final String spanName; + final String oldSpanName; + final Long batchSize; + final String oldOperation; + final String statement; + final boolean empty; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.commands = builder.commands; + this.spanName = builder.spanName; + this.oldSpanName = builder.oldSpanName; + this.batchSize = builder.batchSize; + this.oldOperation = builder.oldOperation; + this.statement = builder.statement; + this.empty = builder.empty; + } + + static Builder builder(String name) { + return new Builder(name); + } + + // the stable-mode db.operation.name (= the span name in stable mode) + String operationName() { + return spanName; + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static final class Builder { + private final String name; + private Consumer commands; + private String spanName; + private String oldSpanName; + private Long batchSize; + private String oldOperation; + private String statement; + private boolean empty; + + Builder(String name) { + this.name = name; + } + + Builder commands(Consumer commands) { + this.commands = commands; + return this; + } + + Builder spanName(String spanName) { + this.spanName = spanName; + return this; + } + + Builder oldSpanName(String oldSpanName) { + this.oldSpanName = oldSpanName; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + Builder oldOperation(String oldOperation) { + this.oldOperation = oldOperation; + return this; + } + + Builder statement(String statement) { + this.statement = statement; + return this; + } + + Builder empty() { + this.empty = true; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java index 1a8ac7534c5c..03b256a9eb59 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java @@ -352,62 +352,28 @@ private static Stream batchScenarios() { return Stream.of( // an empty batch is rejected before sending, so it looks like a single statement but // records the error and emits no db.operation.batch.size - new BatchScenario( - "empty", - emptyList(), - "insert test", - "insert test", - null, - "io.vertx.core.impl.NoStackTraceThrowable"), + BatchScenario.builder("empty") + .tuples(emptyList()) + .stableSpanName("insert test") + .stableSummary("insert test") + .errorType("io.vertx.core.impl.NoStackTraceThrowable") + .build(), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix - new BatchScenario( - "single", - singletonList(Tuple.of(3, "Three")), - "insert test", - "insert test", - null, - null), - new BatchScenario( - "twoSameOperation", - asList(Tuple.of(4, "Four"), Tuple.of(5, "Five")), - "BATCH insert test", - "BATCH insert test", - 2L, - null)) + BatchScenario.builder("single") + .tuples(singletonList(Tuple.of(3, "Three"))) + .stableSpanName("insert test") + .stableSummary("insert test") + .build(), + BatchScenario.builder("twoSameOperation") + .tuples(asList(Tuple.of(4, "Four"), Tuple.of(5, "Five"))) + .stableSpanName("BATCH insert test") + .stableSummary("BATCH insert test") + .batchSize(2) + .build()) .map(Arguments::of); } - private static final class BatchScenario { - final String name; - final List tuples; - final String stableSpanName; - final String stableSummary; - final Long batchSize; - final String errorType; - - BatchScenario( - String name, - List tuples, - String stableSpanName, - String stableSummary, - Long batchSize, - String errorType) { - this.name = name; - this.tuples = tuples; - this.stableSpanName = stableSpanName; - this.stableSummary = stableSummary; - this.batchSize = batchSize; - this.errorType = errorType; - } - - @Override - public String toString() { - // used as the parameterized test display name - return name; - } - } - @Test void testWithTransaction() throws Exception { testing @@ -588,4 +554,74 @@ void testConcurrency() throws Exception { .hasParent(trace.getSpan(0)))); testing.waitAndAssertTraces(assertions); } + + private static final class BatchScenario { + final String name; + final List tuples; + final String stableSpanName; + final String stableSummary; + final Long batchSize; + final String errorType; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.tuples = builder.tuples; + this.stableSpanName = builder.stableSpanName; + this.stableSummary = builder.stableSummary; + this.batchSize = builder.batchSize; + this.errorType = builder.errorType; + } + + static Builder builder(String name) { + return new Builder(name); + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static final class Builder { + private final String name; + private List tuples; + private String stableSpanName; + private String stableSummary; + private Long batchSize; + private String errorType; + + Builder(String name) { + this.name = name; + } + + Builder tuples(List tuples) { + this.tuples = tuples; + return this; + } + + Builder stableSpanName(String stableSpanName) { + this.stableSpanName = stableSpanName; + return this; + } + + Builder stableSummary(String stableSummary) { + this.stableSummary = stableSummary; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + Builder errorType(String errorType) { + this.errorType = errorType; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java index ea21a1a25d00..7a4611071be5 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java @@ -353,62 +353,28 @@ private static Stream batchScenarios() { return Stream.of( // an empty batch is rejected before sending, so it looks like a single statement but // records the error and emits no db.operation.batch.size - new BatchScenario( - "empty", - emptyList(), - "insert test", - "insert test", - null, - "io.vertx.core.VertxException"), + BatchScenario.builder("empty") + .tuples(emptyList()) + .stableSpanName("insert test") + .stableSummary("insert test") + .errorType("io.vertx.core.VertxException") + .build(), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix - new BatchScenario( - "single", - singletonList(Tuple.of(3, "Three")), - "insert test", - "insert test", - null, - null), - new BatchScenario( - "twoSameOperation", - asList(Tuple.of(4, "Four"), Tuple.of(5, "Five")), - "BATCH insert test", - "BATCH insert test", - 2L, - null)) + BatchScenario.builder("single") + .tuples(singletonList(Tuple.of(3, "Three"))) + .stableSpanName("insert test") + .stableSummary("insert test") + .build(), + BatchScenario.builder("twoSameOperation") + .tuples(asList(Tuple.of(4, "Four"), Tuple.of(5, "Five"))) + .stableSpanName("BATCH insert test") + .stableSummary("BATCH insert test") + .batchSize(2) + .build()) .map(Arguments::of); } - private static final class BatchScenario { - final String name; - final List tuples; - final String stableSpanName; - final String stableSummary; - final Long batchSize; - final String errorType; - - BatchScenario( - String name, - List tuples, - String stableSpanName, - String stableSummary, - Long batchSize, - String errorType) { - this.name = name; - this.tuples = tuples; - this.stableSpanName = stableSpanName; - this.stableSummary = stableSummary; - this.batchSize = batchSize; - this.errorType = errorType; - } - - @Override - public String toString() { - // used as the parameterized test display name - return name; - } - } - @Test void testWithTransaction() throws Exception { testing @@ -590,4 +556,74 @@ void testConcurrency() throws Exception { .hasParent(trace.getSpan(0)))); testing.waitAndAssertTraces(assertions); } + + private static final class BatchScenario { + final String name; + final List tuples; + final String stableSpanName; + final String stableSummary; + final Long batchSize; + final String errorType; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.tuples = builder.tuples; + this.stableSpanName = builder.stableSpanName; + this.stableSummary = builder.stableSummary; + this.batchSize = builder.batchSize; + this.errorType = builder.errorType; + } + + static Builder builder(String name) { + return new Builder(name); + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static final class Builder { + private final String name; + private List tuples; + private String stableSpanName; + private String stableSummary; + private Long batchSize; + private String errorType; + + Builder(String name) { + this.name = name; + } + + Builder tuples(List tuples) { + this.tuples = tuples; + return this; + } + + Builder stableSpanName(String stableSpanName) { + this.stableSpanName = stableSpanName; + return this; + } + + Builder stableSummary(String stableSummary) { + this.stableSummary = stableSummary; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + Builder errorType(String errorType) { + this.errorType = errorType; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } From bfb8e1d32fef9912d546cef64e843db96bfea6f5 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 15:09:19 -0700 Subject: [PATCH 11/31] Fix Cassandra batch db.operation.name to not include collection name --- .../instrumentation/cassandra/v3_0/CassandraClientTest.java | 2 +- .../cassandra/common/v4_0/AbstractCassandraTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index 7582dda1a1b3..a7e62853b43a 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -409,7 +409,7 @@ private static Stream batchScenarios() { .oldSpanName("DB Query") .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") .summary("BATCH INSERT batch_same_test.users") - .operation("BATCH INSERT batch_same_test.users") + .operation("BATCH INSERT") .collection("batch_same_test.users") .batchSize(2) .build(), diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index b6cabbeb863c..1614c70016e6 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -318,7 +318,7 @@ private static Stream batchScenarios() { .oldSpanName("DB Query") .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") .summary("BATCH INSERT batch_same_test.users") - .operation("BATCH INSERT batch_same_test.users") + .operation("BATCH INSERT") .collection("batch_same_test.users") .batchSize(2) .build(), From eebf39c6e4c08db7e7df9515fa28bc71e4af1bdd Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 15:16:30 -0700 Subject: [PATCH 12/31] Strengthen R2DBC batch tests with collection names --- .../v1_0/AbstractR2dbcStatementTest.java | 77 ++++++++++++++----- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index e0294637b813..0bf3505d7b73 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -328,6 +328,15 @@ void batchQueries(BatchScenario scenario) { .option(CONNECT_TIMEOUT, Duration.ofSeconds(30)) .build()); + // the batch statements target a real table so that the collection name is captured (in + // db.query.summary and, under old semconv for a single-statement batch, db.sql.table); the + // table must exist for the non-empty batches to execute successfully + if (!scenario.queries.isEmpty()) { + createPlayersTable(connectionFactory); + getTesting().waitForTraces(1); + getTesting().clearData(); + } + Throwable thrown = catchThrowable( () -> @@ -422,6 +431,12 @@ void batchQueries(BatchScenario scenario) { equalTo( maybeStable(DB_OPERATION), emitStableDatabaseSemconv() ? null : scenario.oldOperation), + // db.sql.table is only set under old semconv and only for a + // single-statement batch (multi-statement batches do not capture a + // collection name) + equalTo( + maybeStable(DB_SQL_TABLE), + emitStableDatabaseSemconv() ? null : scenario.oldCollection), equalTo( DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? scenario.batchSize : null), @@ -435,33 +450,50 @@ private static Stream batchScenarios() { // an empty batch produces an error client span BatchScenario.builder("empty").queries(emptyList()).build(), // a single-statement batch is not a batch (size 1), so it emits no - // db.operation.batch.size and no BATCH prefix; under old semconv it carries - // db.statement/db.operation and the operation+namespace span name + // db.operation.batch.size and no BATCH prefix; under old semconv it carries the + // statement, operation, collection and the operation+namespace+table span name BatchScenario.builder("single") - .queries(singletonList("SELECT 1")) - .spanName("SELECT") - .oldSpanName("SELECT " + DB) - .summary("SELECT") - .queryText("SELECT ?") - .oldStatement("SELECT ?") - .oldOperation("SELECT") + .queries(singletonList("INSERT INTO players VALUES (1)")) + .spanName("INSERT players") + .oldSpanName("INSERT " + DB + ".players") + .summary("INSERT players") + .queryText("INSERT INTO players VALUES (?)") + .oldStatement("INSERT INTO players VALUES (?)") + .oldOperation("INSERT") + .oldCollection("players") .build(), // a multi-statement batch emits the BATCH span name, deduplicated db.query.text and - // db.operation.batch.size under stable semconv; under old semconv the individual - // statements are concatenated and the span name is operation+namespace + // db.operation.batch.size under stable semconv; the collection name is captured in the + // summary (BATCH INSERT players). under old semconv the individual statements are + // concatenated but the shared operation and collection are still captured BatchScenario.builder("twoSameOperation") - .queries(asList("SELECT 1", "SELECT 2")) - .spanName("BATCH SELECT") - .oldSpanName("SELECT " + DB) - .summary("BATCH SELECT") - .queryText("SELECT ?") - .oldStatement("SELECT ?; SELECT ?") - .oldOperation("SELECT") + .queries(asList("INSERT INTO players VALUES (2)", "INSERT INTO players VALUES (3)")) + .spanName("BATCH INSERT players") + .oldSpanName("INSERT " + DB + ".players") + .summary("BATCH INSERT players") + .queryText("INSERT INTO players VALUES (?)") + .oldStatement("INSERT INTO players VALUES (?); INSERT INTO players VALUES (?)") + .oldOperation("INSERT") + .oldCollection("players") .batchSize(2) .build()) .map(Arguments::of); } + private void createPlayersTable(ConnectionFactory connectionFactory) { + Mono.from(connectionFactory.create()) + .flatMapMany( + connection -> + Mono.from( + connection + .createStatement( + "CREATE TABLE IF NOT EXISTS players (id INTEGER PRIMARY KEY)") + .execute()) + .flatMapMany(result -> result.map((row, metadata) -> "")) + .concatWith(Mono.from(connection.close()).cast(String.class))) + .blockLast(Duration.ofMinutes(1)); + } + private static class Parameter { private final String system; @@ -527,6 +559,8 @@ private static final class BatchScenario { // the old-semconv db.statement (individual query texts concatenated with "; ") final String oldStatement; final String oldOperation; + // the old-semconv db.sql.table (only captured for a single-statement batch) + final String oldCollection; final Long batchSize; BatchScenario(Builder builder) { @@ -538,6 +572,7 @@ private static final class BatchScenario { this.queryText = builder.queryText; this.oldStatement = builder.oldStatement; this.oldOperation = builder.oldOperation; + this.oldCollection = builder.oldCollection; this.batchSize = builder.batchSize; } @@ -560,6 +595,7 @@ static final class Builder { private String queryText; private String oldStatement; private String oldOperation; + private String oldCollection; private Long batchSize; Builder(String name) { @@ -601,6 +637,11 @@ Builder oldOperation(String oldOperation) { return this; } + Builder oldCollection(String oldCollection) { + this.oldCollection = oldCollection; + return this; + } + Builder batchSize(long batchSize) { this.batchSize = batchSize; return this; From 02a0ff401619ba9b644962c407745a8049139bf5 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 15:21:23 -0700 Subject: [PATCH 13/31] Add two-different-operations case to R2DBC batch test --- .../r2dbc/v1_0/AbstractR2dbcStatementTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index 0bf3505d7b73..e2a59b81486d 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -476,6 +476,23 @@ private static Stream batchScenarios() { .oldOperation("INSERT") .oldCollection("players") .batchSize(2) + .build(), + // a multi-statement batch with different operations has no shared operation or summary, + // so db.query.summary (and the span name) is just BATCH; the individual statements are + // still concatenated into db.query.text / db.statement + BatchScenario.builder("twoDifferentOperations") + .queries( + asList( + "INSERT INTO players VALUES (4)", "UPDATE players SET id = 5 WHERE id = 4")) + .spanName("BATCH") + .oldSpanName("INSERT " + DB + ".players") + .summary("BATCH") + .queryText("INSERT INTO players VALUES (?); UPDATE players SET id = ? WHERE id = ?") + .oldStatement( + "INSERT INTO players VALUES (?); UPDATE players SET id = ? WHERE id = ?") + .oldOperation("INSERT") + .oldCollection("players") + .batchSize(2) .build()) .map(Arguments::of); } From 0259be2a1bc5276b2ae8f0b8ece6f90021c62bc2 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 15:33:47 -0700 Subject: [PATCH 14/31] Return Stream directly from batch test providers --- .../v1_11/AbstractDynamoDbClientTest.java | 82 +++-- .../v2_2/AbstractAws2ClientCoreTest.java | 279 +++++++++--------- .../cassandra/v3_0/CassandraClientTest.java | 145 +++++---- .../common/v4_0/AbstractCassandraTest.java | 145 +++++---- .../AbstractJdbcInstrumentationTest.java | 107 ++++--- .../v1_0/AbstractR2dbcStatementTest.java | 95 +++--- .../redisson/AbstractRedissonClientTest.java | 72 +++-- .../sqlclient/v4_0/VertxSqlClientTest.java | 46 ++- .../sqlclient/v5_0/VertxSqlClientTest.java | 46 ++- 9 files changed, 499 insertions(+), 518 deletions(-) diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java index 652e60d833ab..8ec04fd5a136 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java @@ -45,7 +45,6 @@ import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; public abstract class AbstractDynamoDbClientTest extends AbstractBaseAwsClientTest { @@ -121,48 +120,47 @@ void batchOperation(BatchScenario scenario) throws ReflectiveOperationException response, client, "DynamoDBv2", scenario.awsOperation, "POST", additionalAttributes); } - private static Stream batchScenarios() { + private static Stream batchScenarios() { return Stream.of( - // an empty batch keeps the raw batch operation name and emits no batch size or - // collection name - BatchScenario.builder("getItemEmpty") - .awsOperation("BatchGetItem") - .execute(client -> client.batchGetItem(getItemRequest(0))) - .stableOperation("BatchGetItem") - .build(), - // a single-item batch is not a batch, so it uses the singular item operation - BatchScenario.builder("getItemSingle") - .awsOperation("BatchGetItem") - .execute(client -> client.batchGetItem(getItemRequest(1))) - .stableOperation("GetItem") - .hasCollection() - .build(), - BatchScenario.builder("getItemTwo") - .awsOperation("BatchGetItem") - .execute(client -> client.batchGetItem(getItemRequest(2))) - .stableOperation("BATCH GetItem") - .batchSize(2) - .hasCollection() - .build(), - BatchScenario.builder("writeItemEmpty") - .awsOperation("BatchWriteItem") - .execute(client -> client.batchWriteItem(writeItemRequest(0))) - .stableOperation("BatchWriteItem") - .build(), - BatchScenario.builder("writeItemSingle") - .awsOperation("BatchWriteItem") - .execute(client -> client.batchWriteItem(writeItemRequest(1))) - .stableOperation("WriteItem") - .hasCollection() - .build(), - BatchScenario.builder("writeItemTwo") - .awsOperation("BatchWriteItem") - .execute(client -> client.batchWriteItem(writeItemRequest(2))) - .stableOperation("BATCH WriteItem") - .batchSize(2) - .hasCollection() - .build()) - .map(Arguments::of); + // an empty batch keeps the raw batch operation name and emits no batch size or + // collection name + BatchScenario.builder("getItemEmpty") + .awsOperation("BatchGetItem") + .execute(client -> client.batchGetItem(getItemRequest(0))) + .stableOperation("BatchGetItem") + .build(), + // a single-item batch is not a batch, so it uses the singular item operation + BatchScenario.builder("getItemSingle") + .awsOperation("BatchGetItem") + .execute(client -> client.batchGetItem(getItemRequest(1))) + .stableOperation("GetItem") + .hasCollection() + .build(), + BatchScenario.builder("getItemTwo") + .awsOperation("BatchGetItem") + .execute(client -> client.batchGetItem(getItemRequest(2))) + .stableOperation("BATCH GetItem") + .batchSize(2) + .hasCollection() + .build(), + BatchScenario.builder("writeItemEmpty") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(writeItemRequest(0))) + .stableOperation("BatchWriteItem") + .build(), + BatchScenario.builder("writeItemSingle") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(writeItemRequest(1))) + .stableOperation("WriteItem") + .hasCollection() + .build(), + BatchScenario.builder("writeItemTwo") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(writeItemRequest(2))) + .stableOperation("BATCH WriteItem") + .batchSize(2) + .hasCollection() + .build()); } private static BatchGetItemRequest getItemRequest(int count) { diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java index 71f9c1dc1bfb..f958abbd48ba 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java @@ -563,149 +563,144 @@ void batchOperation(BatchScenario scenario) { } @SuppressWarnings("deprecation") // uses deprecated semconv - private static Stream batchScenarios() { + private static Stream batchScenarios() { return Stream.of( - // an empty batch still produces a span, but keeps the raw batch operation name and - // emits no db.operation.batch.size, db.collection.name or table-name attributes - BatchScenario.builder("getItemEmpty") - .awsOperation("BatchGetItem") - .responseContent("{\"ConsumedCapacity\":[]}") - .execute(c -> c.batchGetItem(b -> b.requestItems(ImmutableMap.of()))) - .stableOperation("BatchGetItem") - .build(), - // a single-item batch is not a batch, so it uses the singular item operation and emits - // no db.operation.batch.size - BatchScenario.builder("getItemSingle") - .awsOperation("BatchGetItem") - .responseContent(getResponseContent("BatchGetItem")) - .execute( - c -> - c.batchGetItem( - b -> - b.requestItems( - ImmutableMap.of( - "sometable", - KeysAndAttributes.builder() - .keys( - singletonList( - ImmutableMap.of( - "key", - AttributeValue.builder() - .s("value") - .build()))) - .build())))) - .stableOperation("GetItem") - .hasCollection() - .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") - .assertMetric() - .build(), - BatchScenario.builder("getItemTwo") - .awsOperation("BatchGetItem") - .responseContent(getResponseContent("BatchGetItem")) - .execute( - c -> - c.batchGetItem( - b -> - b.requestItems( - ImmutableMap.of( - "sometable", - KeysAndAttributes.builder() - .keys( - asList( - ImmutableMap.of( - "key", - AttributeValue.builder() - .s("value") - .build()), - ImmutableMap.of( - "key", - AttributeValue.builder() - .s("anotherValue") - .build()))) - .build())))) - .stableOperation("BATCH GetItem") - .hasCollection() - .batchSize(2) - .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") - .assertMetric() - .build(), - BatchScenario.builder("writeItemEmpty") - .awsOperation("BatchWriteItem") - .responseContent("{\"ConsumedCapacity\":[]}") - .execute(c -> c.batchWriteItem(b -> b.requestItems(ImmutableMap.of()))) - .stableOperation("BatchWriteItem") - .build(), - // a single-item batch is not a batch, so it uses the singular item operation and emits - // no db.operation.batch.size - BatchScenario.builder("writeItemSingle") - .awsOperation("BatchWriteItem") - .responseContent(getResponseContent("BatchWriteItem")) - .execute( - c -> - c.batchWriteItem( - b -> - b.requestItems( - ImmutableMap.of( - "sometable", - singletonList( - WriteRequest.builder() - .putRequest( - PutRequest.builder() - .item( - ImmutableMap.of( - "key", - AttributeValue.builder() - .s("value") - .build())) - .build()) - .build()))))) - .stableOperation("WriteItem") - .hasCollection() - .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") - .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") - .assertMetric() - .build(), - BatchScenario.builder("writeItemTwo") - .awsOperation("BatchWriteItem") - .responseContent(getResponseContent("BatchWriteItem")) - .execute( - c -> - c.batchWriteItem( - b -> - b.requestItems( - ImmutableMap.of( - "sometable", - asList( - WriteRequest.builder() - .putRequest( - PutRequest.builder() - .item( - ImmutableMap.of( - "key", - AttributeValue.builder() - .s("value") - .build())) - .build()) - .build(), - WriteRequest.builder() - .putRequest( - PutRequest.builder() - .item( - ImmutableMap.of( - "key", - AttributeValue.builder() - .s("anotherValue") - .build())) - .build()) - .build()))))) - .stableOperation("BATCH WriteItem") - .hasCollection() - .batchSize(2) - .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") - .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") - .assertMetric() - .build()) - .map(Arguments::of); + // an empty batch still produces a span, but keeps the raw batch operation name and + // emits no db.operation.batch.size, db.collection.name or table-name attributes + BatchScenario.builder("getItemEmpty") + .awsOperation("BatchGetItem") + .responseContent("{\"ConsumedCapacity\":[]}") + .execute(c -> c.batchGetItem(b -> b.requestItems(ImmutableMap.of()))) + .stableOperation("BatchGetItem") + .build(), + // a single-item batch is not a batch, so it uses the singular item operation and emits + // no db.operation.batch.size + BatchScenario.builder("getItemSingle") + .awsOperation("BatchGetItem") + .responseContent(getResponseContent("BatchGetItem")) + .execute( + c -> + c.batchGetItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + KeysAndAttributes.builder() + .keys( + singletonList( + ImmutableMap.of( + "key", + AttributeValue.builder().s("value").build()))) + .build())))) + .stableOperation("GetItem") + .hasCollection() + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .assertMetric() + .build(), + BatchScenario.builder("getItemTwo") + .awsOperation("BatchGetItem") + .responseContent(getResponseContent("BatchGetItem")) + .execute( + c -> + c.batchGetItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + KeysAndAttributes.builder() + .keys( + asList( + ImmutableMap.of( + "key", + AttributeValue.builder().s("value").build()), + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("anotherValue") + .build()))) + .build())))) + .stableOperation("BATCH GetItem") + .hasCollection() + .batchSize(2) + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .assertMetric() + .build(), + BatchScenario.builder("writeItemEmpty") + .awsOperation("BatchWriteItem") + .responseContent("{\"ConsumedCapacity\":[]}") + .execute(c -> c.batchWriteItem(b -> b.requestItems(ImmutableMap.of()))) + .stableOperation("BatchWriteItem") + .build(), + // a single-item batch is not a batch, so it uses the singular item operation and emits + // no db.operation.batch.size + BatchScenario.builder("writeItemSingle") + .awsOperation("BatchWriteItem") + .responseContent(getResponseContent("BatchWriteItem")) + .execute( + c -> + c.batchWriteItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + singletonList( + WriteRequest.builder() + .putRequest( + PutRequest.builder() + .item( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build())) + .build()) + .build()))))) + .stableOperation("WriteItem") + .hasCollection() + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() + .build(), + BatchScenario.builder("writeItemTwo") + .awsOperation("BatchWriteItem") + .responseContent(getResponseContent("BatchWriteItem")) + .execute( + c -> + c.batchWriteItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + asList( + WriteRequest.builder() + .putRequest( + PutRequest.builder() + .item( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build())) + .build()) + .build(), + WriteRequest.builder() + .putRequest( + PutRequest.builder() + .item( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("anotherValue") + .build())) + .build()) + .build()))))) + .stableOperation("BATCH WriteItem") + .hasCollection() + .batchSize(2) + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() + .build()); } private static String getResponseContent(String operation) { diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index a7e62853b43a..11d2582acde6 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -362,80 +362,79 @@ void batchStatement(BatchScenario scenario) { : scenario.oldCollection)))); } - private static Stream batchScenarios() { + private static Stream batchScenarios() { return Stream.of( - // an empty batch still produces a client span, but with no query text, summary, - // operation or batch size; the span name falls back to the database system name - BatchScenario.builder("empty") - .keyspace("batch_empty_test") - .buildBatch(session -> new BatchStatement()) - .spanName("cassandra") - .oldSpanName("DB Query") - .build(), - // a single-statement batch is executed as a normal statement (not a batch): it has the - // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no - // db.operation.batch.size - BatchScenario.builder("single") - .keyspace("batch_single_test") - .buildBatch( - session -> { - PreparedStatement insert = - session.prepare( - "INSERT INTO batch_single_test.users (name, age) values (?, ?)"); - return new BatchStatement().add(insert.bind("alice", 1)); - }) - .spanName("INSERT batch_single_test.users") - .oldSpanName("INSERT batch_single_test.users") - .statement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") - .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") - .summary("INSERT batch_single_test.users") - .operation("INSERT") - .oldOperation("INSERT") - .collection("batch_single_test.users") - .oldCollection("batch_single_test.users") - .build(), - BatchScenario.builder("twoSameOperation") - .keyspace("batch_same_test") - .buildBatch( - session -> { - PreparedStatement insert = - session.prepare( - "INSERT INTO batch_same_test.users (name, age) values (?, ?)"); - return new BatchStatement() - .add(insert.bind("alice", 1)) - .add(insert.bind("bob", 2)); - }) - .spanName("BATCH INSERT batch_same_test.users") - .oldSpanName("DB Query") - .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") - .summary("BATCH INSERT batch_same_test.users") - .operation("BATCH INSERT") - .collection("batch_same_test.users") - .batchSize(2) - .build(), - BatchScenario.builder("twoDifferentOperations") - .keyspace("batch_mixed_test") - .buildBatch( - session -> { - PreparedStatement insert = - session.prepare( - "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); - return new BatchStatement() - .add(insert.bind(1)) - .add( - new SimpleStatement( - "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); - }) - .spanName("BATCH") - .oldSpanName("DB Query") - .statement( - "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") - .summary("BATCH") - .operation("BATCH") - .collection("batch_mixed_test.users") - .batchSize(2) - .build()) - .map(Arguments::of); + // an empty batch still produces a client span, but with no query text, summary, + // operation or batch size; the span name falls back to the database system name + BatchScenario.builder("empty") + .keyspace("batch_empty_test") + .buildBatch(session -> new BatchStatement()) + .spanName("cassandra") + .oldSpanName("DB Query") + .build(), + // a single-statement batch is executed as a normal statement (not a batch): it has the + // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no + // db.operation.batch.size + BatchScenario.builder("single") + .keyspace("batch_single_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_single_test.users (name, age) values (?, ?)"); + return new BatchStatement().add(insert.bind("alice", 1)); + }) + .spanName("INSERT batch_single_test.users") + .oldSpanName("INSERT batch_single_test.users") + .statement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") + .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") + .summary("INSERT batch_single_test.users") + .operation("INSERT") + .oldOperation("INSERT") + .collection("batch_single_test.users") + .oldCollection("batch_single_test.users") + .build(), + BatchScenario.builder("twoSameOperation") + .keyspace("batch_same_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_same_test.users (name, age) values (?, ?)"); + return new BatchStatement() + .add(insert.bind("alice", 1)) + .add(insert.bind("bob", 2)); + }) + .spanName("BATCH INSERT batch_same_test.users") + .oldSpanName("DB Query") + .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") + .summary("BATCH INSERT batch_same_test.users") + .operation("BATCH INSERT") + .collection("batch_same_test.users") + .batchSize(2) + .build(), + BatchScenario.builder("twoDifferentOperations") + .keyspace("batch_mixed_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); + return new BatchStatement() + .add(insert.bind(1)) + .add( + new SimpleStatement( + "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); + }) + .spanName("BATCH") + .oldSpanName("DB Query") + .statement( + "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") + .summary("BATCH") + .operation("BATCH") + .collection("batch_mixed_test.users") + .batchSize(2) + .build()); } private static Stream provideSyncParameters() { diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index 1614c70016e6..acb5370aa37f 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -271,80 +271,79 @@ void batchStatement(BatchScenario scenario) { maybeStable(DB_CASSANDRA_SPECULATIVE_EXECUTION_COUNT), 0)))); } - private static Stream batchScenarios() { + private static Stream batchScenarios() { return Stream.of( - // an empty batch still produces a client span, but with no query text, summary, - // operation or batch size; the span name falls back to the database system name - BatchScenario.builder("empty") - .keyspace("batch_empty_test") - .buildBatch(session -> BatchStatement.newInstance(DefaultBatchType.LOGGED)) - .spanName("cassandra") - .oldSpanName("DB Query") - .build(), - // a single-statement batch is executed as a normal statement (not a batch): it has the - // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no - // db.operation.batch.size - BatchScenario.builder("single") - .keyspace("batch_single_test") - .buildBatch( - session -> { - PreparedStatement insert = - session.prepare( - "INSERT INTO batch_single_test.users (name, age) values (?, ?)"); - return BatchStatement.newInstance( - DefaultBatchType.LOGGED, insert.bind("alice", 1)); - }) - .spanName("INSERT batch_single_test.users") - .oldSpanName("INSERT batch_single_test.users") - .statement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") - .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") - .summary("INSERT batch_single_test.users") - .operation("INSERT") - .oldOperation("INSERT") - .collection("batch_single_test.users") - .oldCollection("batch_single_test.users") - .build(), - BatchScenario.builder("twoSameOperation") - .keyspace("batch_same_test") - .buildBatch( - session -> { - PreparedStatement insert = - session.prepare( - "INSERT INTO batch_same_test.users (name, age) values (?, ?)"); - return BatchStatement.newInstance( - DefaultBatchType.LOGGED, insert.bind("alice", 1), insert.bind("bob", 2)); - }) - .spanName("BATCH INSERT batch_same_test.users") - .oldSpanName("DB Query") - .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") - .summary("BATCH INSERT batch_same_test.users") - .operation("BATCH INSERT") - .collection("batch_same_test.users") - .batchSize(2) - .build(), - BatchScenario.builder("twoDifferentOperations") - .keyspace("batch_mixed_test") - .buildBatch( - session -> { - PreparedStatement insert = - session.prepare( - "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); - return BatchStatement.newInstance( - DefaultBatchType.LOGGED, - insert.bind(1), - SimpleStatement.newInstance( - "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); - }) - .spanName("BATCH") - .oldSpanName("DB Query") - .statement( - "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") - .summary("BATCH") - .operation("BATCH") - .collection("batch_mixed_test.users") - .batchSize(2) - .build()) - .map(Arguments::of); + // an empty batch still produces a client span, but with no query text, summary, + // operation or batch size; the span name falls back to the database system name + BatchScenario.builder("empty") + .keyspace("batch_empty_test") + .buildBatch(session -> BatchStatement.newInstance(DefaultBatchType.LOGGED)) + .spanName("cassandra") + .oldSpanName("DB Query") + .build(), + // a single-statement batch is executed as a normal statement (not a batch): it has the + // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no + // db.operation.batch.size + BatchScenario.builder("single") + .keyspace("batch_single_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_single_test.users (name, age) values (?, ?)"); + return BatchStatement.newInstance( + DefaultBatchType.LOGGED, insert.bind("alice", 1)); + }) + .spanName("INSERT batch_single_test.users") + .oldSpanName("INSERT batch_single_test.users") + .statement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") + .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") + .summary("INSERT batch_single_test.users") + .operation("INSERT") + .oldOperation("INSERT") + .collection("batch_single_test.users") + .oldCollection("batch_single_test.users") + .build(), + BatchScenario.builder("twoSameOperation") + .keyspace("batch_same_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_same_test.users (name, age) values (?, ?)"); + return BatchStatement.newInstance( + DefaultBatchType.LOGGED, insert.bind("alice", 1), insert.bind("bob", 2)); + }) + .spanName("BATCH INSERT batch_same_test.users") + .oldSpanName("DB Query") + .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") + .summary("BATCH INSERT batch_same_test.users") + .operation("BATCH INSERT") + .collection("batch_same_test.users") + .batchSize(2) + .build(), + BatchScenario.builder("twoDifferentOperations") + .keyspace("batch_mixed_test") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare( + "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); + return BatchStatement.newInstance( + DefaultBatchType.LOGGED, + insert.bind(1), + SimpleStatement.newInstance( + "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); + }) + .spanName("BATCH") + .oldSpanName("DB Query") + .statement( + "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") + .summary("BATCH") + .operation("BATCH") + .collection("batch_mixed_test.users") + .batchSize(2) + .build()); } @ParameterizedTest(name = "{index}: {0}") diff --git a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java index 72b944ca4830..27a3c27ce348 100644 --- a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java +++ b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java @@ -1883,61 +1883,60 @@ void testProxyPreparedStatement() throws SQLException { // expected executeBatch() result, and the expected client span. batch telemetry comes from the // shared SQL extractors and is database-agnostic, so a single (in-memory) database is enough to // lock down its shape - static Stream batchCasesStream() { + static Stream batchCasesStream() { return Stream.of( - // an empty batch still produces a client span, but with no query text or batch size; - // the span name falls back to the database namespace in both modes - BatchScenario.builder() - .name("empty") - .spanName(DATABASE_NAME_LOWER) - .oldSpanName(DATABASE_NAME_LOWER) - .build(), - // a single-statement batch is not a batch (size 1), so it looks like a normal - // statement and carries db.statement/db.operation/db.sql.table under old semconv - BatchScenario.builder() - .name("single") - .createTable("stmt_batch_single") - .addQuery("INSERT INTO stmt_batch_single VALUES(1)") - .expectedResult(1) - .spanName("INSERT stmt_batch_single") - .oldSpanName("INSERT " + DATABASE_NAME_LOWER + ".stmt_batch_single") - .queryText("INSERT INTO stmt_batch_single VALUES(?)") - .oldStatement("INSERT INTO stmt_batch_single VALUES(?)") - .summary("INSERT stmt_batch_single") - .oldOperation("INSERT") - .oldTable("stmt_batch_single") - .build(), - // a multi-statement batch only emits db.query.text/summary and BATCH span name under - // stable semconv; under old semconv it has no statement-level attributes and the span - // name falls back to the namespace - BatchScenario.builder() - .name("twoSameOperation") - .createTable("stmt_batch_same") - .addQuery("INSERT INTO stmt_batch_same VALUES(1)") - .addQuery("INSERT INTO stmt_batch_same VALUES(2)") - .expectedResult(1, 1) - .spanName("BATCH INSERT stmt_batch_same") - .oldSpanName(DATABASE_NAME_LOWER) - .queryText("INSERT INTO stmt_batch_same VALUES(?)") - .summary("BATCH INSERT stmt_batch_same") - .batchSize(2) - .build(), - BatchScenario.builder() - .name("twoDifferentOperations") - .createTable("stmt_batch_diff_1") - .createTable("stmt_batch_diff_2") - .addQuery("INSERT INTO stmt_batch_diff_1 VALUES(1)") - .addQuery("INSERT INTO stmt_batch_diff_2 VALUES(2)") - .expectedResult(1, 1) - .spanName("BATCH") - .oldSpanName(DATABASE_NAME_LOWER) - .queryText( - "INSERT INTO stmt_batch_diff_1 VALUES(?); INSERT INTO stmt_batch_diff_2" - + " VALUES(?)") - .summary("BATCH") - .batchSize(2) - .build()) - .map(Arguments::of); + // an empty batch still produces a client span, but with no query text or batch size; + // the span name falls back to the database namespace in both modes + BatchScenario.builder() + .name("empty") + .spanName(DATABASE_NAME_LOWER) + .oldSpanName(DATABASE_NAME_LOWER) + .build(), + // a single-statement batch is not a batch (size 1), so it looks like a normal + // statement and carries db.statement/db.operation/db.sql.table under old semconv + BatchScenario.builder() + .name("single") + .createTable("stmt_batch_single") + .addQuery("INSERT INTO stmt_batch_single VALUES(1)") + .expectedResult(1) + .spanName("INSERT stmt_batch_single") + .oldSpanName("INSERT " + DATABASE_NAME_LOWER + ".stmt_batch_single") + .queryText("INSERT INTO stmt_batch_single VALUES(?)") + .oldStatement("INSERT INTO stmt_batch_single VALUES(?)") + .summary("INSERT stmt_batch_single") + .oldOperation("INSERT") + .oldTable("stmt_batch_single") + .build(), + // a multi-statement batch only emits db.query.text/summary and BATCH span name under + // stable semconv; under old semconv it has no statement-level attributes and the span + // name falls back to the namespace + BatchScenario.builder() + .name("twoSameOperation") + .createTable("stmt_batch_same") + .addQuery("INSERT INTO stmt_batch_same VALUES(1)") + .addQuery("INSERT INTO stmt_batch_same VALUES(2)") + .expectedResult(1, 1) + .spanName("BATCH INSERT stmt_batch_same") + .oldSpanName(DATABASE_NAME_LOWER) + .queryText("INSERT INTO stmt_batch_same VALUES(?)") + .summary("BATCH INSERT stmt_batch_same") + .batchSize(2) + .build(), + BatchScenario.builder() + .name("twoDifferentOperations") + .createTable("stmt_batch_diff_1") + .createTable("stmt_batch_diff_2") + .addQuery("INSERT INTO stmt_batch_diff_1 VALUES(1)") + .addQuery("INSERT INTO stmt_batch_diff_2 VALUES(2)") + .expectedResult(1, 1) + .spanName("BATCH") + .oldSpanName(DATABASE_NAME_LOWER) + .queryText( + "INSERT INTO stmt_batch_diff_1 VALUES(?); INSERT INTO stmt_batch_diff_2" + + " VALUES(?)") + .summary("BATCH") + .batchSize(2) + .build()); } @ParameterizedTest diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index e2a59b81486d..870c7ae3cb1f 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -445,56 +445,53 @@ void batchQueries(BatchScenario scenario) { equalTo(SERVER_PORT, port)))); } - private static Stream batchScenarios() { + private static Stream batchScenarios() { return Stream.of( - // an empty batch produces an error client span - BatchScenario.builder("empty").queries(emptyList()).build(), - // a single-statement batch is not a batch (size 1), so it emits no - // db.operation.batch.size and no BATCH prefix; under old semconv it carries the - // statement, operation, collection and the operation+namespace+table span name - BatchScenario.builder("single") - .queries(singletonList("INSERT INTO players VALUES (1)")) - .spanName("INSERT players") - .oldSpanName("INSERT " + DB + ".players") - .summary("INSERT players") - .queryText("INSERT INTO players VALUES (?)") - .oldStatement("INSERT INTO players VALUES (?)") - .oldOperation("INSERT") - .oldCollection("players") - .build(), - // a multi-statement batch emits the BATCH span name, deduplicated db.query.text and - // db.operation.batch.size under stable semconv; the collection name is captured in the - // summary (BATCH INSERT players). under old semconv the individual statements are - // concatenated but the shared operation and collection are still captured - BatchScenario.builder("twoSameOperation") - .queries(asList("INSERT INTO players VALUES (2)", "INSERT INTO players VALUES (3)")) - .spanName("BATCH INSERT players") - .oldSpanName("INSERT " + DB + ".players") - .summary("BATCH INSERT players") - .queryText("INSERT INTO players VALUES (?)") - .oldStatement("INSERT INTO players VALUES (?); INSERT INTO players VALUES (?)") - .oldOperation("INSERT") - .oldCollection("players") - .batchSize(2) - .build(), - // a multi-statement batch with different operations has no shared operation or summary, - // so db.query.summary (and the span name) is just BATCH; the individual statements are - // still concatenated into db.query.text / db.statement - BatchScenario.builder("twoDifferentOperations") - .queries( - asList( - "INSERT INTO players VALUES (4)", "UPDATE players SET id = 5 WHERE id = 4")) - .spanName("BATCH") - .oldSpanName("INSERT " + DB + ".players") - .summary("BATCH") - .queryText("INSERT INTO players VALUES (?); UPDATE players SET id = ? WHERE id = ?") - .oldStatement( - "INSERT INTO players VALUES (?); UPDATE players SET id = ? WHERE id = ?") - .oldOperation("INSERT") - .oldCollection("players") - .batchSize(2) - .build()) - .map(Arguments::of); + // an empty batch produces an error client span + BatchScenario.builder("empty").queries(emptyList()).build(), + // a single-statement batch is not a batch (size 1), so it emits no + // db.operation.batch.size and no BATCH prefix; under old semconv it carries the + // statement, operation, collection and the operation+namespace+table span name + BatchScenario.builder("single") + .queries(singletonList("INSERT INTO players VALUES (1)")) + .spanName("INSERT players") + .oldSpanName("INSERT " + DB + ".players") + .summary("INSERT players") + .queryText("INSERT INTO players VALUES (?)") + .oldStatement("INSERT INTO players VALUES (?)") + .oldOperation("INSERT") + .oldCollection("players") + .build(), + // a multi-statement batch emits the BATCH span name, deduplicated db.query.text and + // db.operation.batch.size under stable semconv; the collection name is captured in the + // summary (BATCH INSERT players). under old semconv the individual statements are + // concatenated but the shared operation and collection are still captured + BatchScenario.builder("twoSameOperation") + .queries(asList("INSERT INTO players VALUES (2)", "INSERT INTO players VALUES (3)")) + .spanName("BATCH INSERT players") + .oldSpanName("INSERT " + DB + ".players") + .summary("BATCH INSERT players") + .queryText("INSERT INTO players VALUES (?)") + .oldStatement("INSERT INTO players VALUES (?); INSERT INTO players VALUES (?)") + .oldOperation("INSERT") + .oldCollection("players") + .batchSize(2) + .build(), + // a multi-statement batch with different operations has no shared operation or summary, + // so db.query.summary (and the span name) is just BATCH; the individual statements are + // still concatenated into db.query.text / db.statement + BatchScenario.builder("twoDifferentOperations") + .queries( + asList("INSERT INTO players VALUES (4)", "UPDATE players SET id = 5 WHERE id = 4")) + .spanName("BATCH") + .oldSpanName("INSERT " + DB + ".players") + .summary("BATCH") + .queryText("INSERT INTO players VALUES (?); UPDATE players SET id = ? WHERE id = ?") + .oldStatement("INSERT INTO players VALUES (?); UPDATE players SET id = ? WHERE id = ?") + .oldOperation("INSERT") + .oldCollection("players") + .batchSize(2) + .build()); } private void createPlayersTable(ConnectionFactory connectionFactory) { diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index f9751cccf5f5..83425c2f3063 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -54,7 +54,6 @@ import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.redisson.Redisson; import org.redisson.api.BatchOptions; @@ -302,43 +301,42 @@ void batchCommand(BatchScenario scenario) throws ReflectiveOperationException { equalTo(maybeStable(DB_STATEMENT), scenario.statement)))); } - private static Stream batchScenarios() { + private static Stream batchScenarios() { return Stream.of( - // an empty batch fails to execute and produces no span - BatchScenario.builder("empty").commands(batch -> {}).empty().build(), - // a single-command batch is executed as a normal command (not a pipeline): the span is - // named after the command, it carries db.operation (old) / db.operation.name (stable), - // and emits no db.operation.batch.size - BatchScenario.builder("single") - .commands(batch -> batch.getBucket("batch1").setAsync("v1")) - .spanName("SET") - .oldSpanName("SET") - .oldOperation("SET") - .statement("SET batch1 ?") - .build(), - BatchScenario.builder("twoSameOperation") - .commands( - batch -> { - batch.getBucket("batch1").setAsync("v1"); - batch.getBucket("batch2").setAsync("v2"); - }) - .spanName("PIPELINE SET") - .oldSpanName("DB Query") - .batchSize(2) - .statement("SET batch1 ?;SET batch2 ?") - .build(), - BatchScenario.builder("twoDifferentOperations") - .commands( - batch -> { - batch.getBucket("batch1").setAsync("v1"); - batch.getBucket("batch1").getAsync(); - }) - .spanName("PIPELINE") - .oldSpanName("DB Query") - .batchSize(2) - .statement("SET batch1 ?;GET batch1") - .build()) - .map(Arguments::of); + // an empty batch fails to execute and produces no span + BatchScenario.builder("empty").commands(batch -> {}).empty().build(), + // a single-command batch is executed as a normal command (not a pipeline): the span is + // named after the command, it carries db.operation (old) / db.operation.name (stable), + // and emits no db.operation.batch.size + BatchScenario.builder("single") + .commands(batch -> batch.getBucket("batch1").setAsync("v1")) + .spanName("SET") + .oldSpanName("SET") + .oldOperation("SET") + .statement("SET batch1 ?") + .build(), + BatchScenario.builder("twoSameOperation") + .commands( + batch -> { + batch.getBucket("batch1").setAsync("v1"); + batch.getBucket("batch2").setAsync("v2"); + }) + .spanName("PIPELINE SET") + .oldSpanName("DB Query") + .batchSize(2) + .statement("SET batch1 ?;SET batch2 ?") + .build(), + BatchScenario.builder("twoDifferentOperations") + .commands( + batch -> { + batch.getBucket("batch1").setAsync("v1"); + batch.getBucket("batch1").getAsync(); + }) + .spanName("PIPELINE") + .oldSpanName("DB Query") + .batchSize(2) + .statement("SET batch1 ?;GET batch1") + .build()); } private static void invokeExecute(RBatch batch) throws ReflectiveOperationException { diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java index 03b256a9eb59..7022c46d6559 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java @@ -61,7 +61,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -348,30 +347,29 @@ void testBatch(BatchScenario scenario) throws Exception { equalTo(SERVER_PORT, port)))); } - private static Stream batchScenarios() { + private static Stream batchScenarios() { return Stream.of( - // an empty batch is rejected before sending, so it looks like a single statement but - // records the error and emits no db.operation.batch.size - BatchScenario.builder("empty") - .tuples(emptyList()) - .stableSpanName("insert test") - .stableSummary("insert test") - .errorType("io.vertx.core.impl.NoStackTraceThrowable") - .build(), - // a single-statement batch is not a batch (size 1), so it emits no - // db.operation.batch.size and no BATCH prefix - BatchScenario.builder("single") - .tuples(singletonList(Tuple.of(3, "Three"))) - .stableSpanName("insert test") - .stableSummary("insert test") - .build(), - BatchScenario.builder("twoSameOperation") - .tuples(asList(Tuple.of(4, "Four"), Tuple.of(5, "Five"))) - .stableSpanName("BATCH insert test") - .stableSummary("BATCH insert test") - .batchSize(2) - .build()) - .map(Arguments::of); + // an empty batch is rejected before sending, so it looks like a single statement but + // records the error and emits no db.operation.batch.size + BatchScenario.builder("empty") + .tuples(emptyList()) + .stableSpanName("insert test") + .stableSummary("insert test") + .errorType("io.vertx.core.impl.NoStackTraceThrowable") + .build(), + // a single-statement batch is not a batch (size 1), so it emits no + // db.operation.batch.size and no BATCH prefix + BatchScenario.builder("single") + .tuples(singletonList(Tuple.of(3, "Three"))) + .stableSpanName("insert test") + .stableSummary("insert test") + .build(), + BatchScenario.builder("twoSameOperation") + .tuples(asList(Tuple.of(4, "Four"), Tuple.of(5, "Five"))) + .stableSpanName("BATCH insert test") + .stableSummary("BATCH insert test") + .batchSize(2) + .build()); } @Test diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java index 7a4611071be5..2ad734b0ccc3 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java @@ -61,7 +61,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -349,30 +348,29 @@ void testBatch(BatchScenario scenario) throws Exception { equalTo(SERVER_PORT, port)))); } - private static Stream batchScenarios() { + private static Stream batchScenarios() { return Stream.of( - // an empty batch is rejected before sending, so it looks like a single statement but - // records the error and emits no db.operation.batch.size - BatchScenario.builder("empty") - .tuples(emptyList()) - .stableSpanName("insert test") - .stableSummary("insert test") - .errorType("io.vertx.core.VertxException") - .build(), - // a single-statement batch is not a batch (size 1), so it emits no - // db.operation.batch.size and no BATCH prefix - BatchScenario.builder("single") - .tuples(singletonList(Tuple.of(3, "Three"))) - .stableSpanName("insert test") - .stableSummary("insert test") - .build(), - BatchScenario.builder("twoSameOperation") - .tuples(asList(Tuple.of(4, "Four"), Tuple.of(5, "Five"))) - .stableSpanName("BATCH insert test") - .stableSummary("BATCH insert test") - .batchSize(2) - .build()) - .map(Arguments::of); + // an empty batch is rejected before sending, so it looks like a single statement but + // records the error and emits no db.operation.batch.size + BatchScenario.builder("empty") + .tuples(emptyList()) + .stableSpanName("insert test") + .stableSummary("insert test") + .errorType("io.vertx.core.VertxException") + .build(), + // a single-statement batch is not a batch (size 1), so it emits no + // db.operation.batch.size and no BATCH prefix + BatchScenario.builder("single") + .tuples(singletonList(Tuple.of(3, "Three"))) + .stableSpanName("insert test") + .stableSummary("insert test") + .build(), + BatchScenario.builder("twoSameOperation") + .tuples(asList(Tuple.of(4, "Four"), Tuple.of(5, "Five"))) + .stableSpanName("BATCH insert test") + .stableSummary("BATCH insert test") + .batchSize(2) + .build()); } @Test From 4c16e0f8f5e4c4a341dd36247da91d8d8b7b7ec0 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 15:43:46 -0700 Subject: [PATCH 15/31] spotless --- .../instrumentation/api/incubator/semconv/db/MultiQuery.java | 3 ++- .../incubator/semconv/db/SqlClientAttributesExtractorTest.java | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java index da33c62f55a2..b9c28309332b 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java @@ -76,7 +76,8 @@ static class Builder { private final UniqueValue uniqueOperationName = new UniqueValue(); private final UniqueValue uniqueCollectionName = new UniqueValue(); - @SuppressWarnings("deprecation") // getOperationName()/getCollectionName() package-private in 3.0 + @SuppressWarnings( + "deprecation") // getOperationName()/getCollectionName() package-private in 3.0 void add(SqlQuery analyzedQuery, @Nullable String queryText) { uniqueStoredProcedureName.set(analyzedQuery.getStoredProcedureName()); uniqueQueryTexts.add(queryText); diff --git a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java index 543101e2cdef..307ee95dd976 100644 --- a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java +++ b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java @@ -433,8 +433,7 @@ void shouldExtractMultiQueryBatchOperationNameWhenSingleOperationAndCollection() Map mixedCollections = new HashMap<>(); mixedCollections.put("db.namespace", "potatoes"); mixedCollections.put( - "db.query.texts", - asList("INSERT INTO potato VALUES(1)", "INSERT INTO tomato VALUES(2)")); + "db.query.texts", asList("INSERT INTO potato VALUES(1)", "INSERT INTO tomato VALUES(2)")); mixedCollections.put(DB_OPERATION_BATCH_SIZE.getKey(), 2L); Context context = Context.root(); From 5620570cf5103980e628efb3802048a961290ca8 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 16:03:52 -0700 Subject: [PATCH 16/31] Use a shared items(id, num) table across SQL batch test matrices --- .../cassandra/v3_0/CassandraClientTest.java | 47 ++++++++-------- .../common/v4_0/AbstractCassandraTest.java | 46 ++++++++-------- .../AbstractJdbcInstrumentationTest.java | 46 ++++++++-------- .../v1_0/AbstractR2dbcStatementTest.java | 54 +++++++++++-------- 4 files changed, 98 insertions(+), 95 deletions(-) diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index 11d2582acde6..6db46fc1b9ca 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -312,8 +312,7 @@ void batchStatement(BatchScenario scenario) { "CREATE KEYSPACE " + scenario.keyspace + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); - session.execute( - "CREATE TABLE " + scenario.keyspace + ".users ( name text PRIMARY KEY, age int )"); + session.execute("CREATE TABLE " + scenario.keyspace + ".items ( id int PRIMARY KEY, num int )"); testing.waitForTraces(3); testing.clearData(); @@ -381,36 +380,33 @@ private static Stream batchScenarios() { session -> { PreparedStatement insert = session.prepare( - "INSERT INTO batch_single_test.users (name, age) values (?, ?)"); - return new BatchStatement().add(insert.bind("alice", 1)); + "INSERT INTO batch_single_test.items (id, num) values (?, ?)"); + return new BatchStatement().add(insert.bind(1, 1)); }) - .spanName("INSERT batch_single_test.users") - .oldSpanName("INSERT batch_single_test.users") - .statement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") - .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") - .summary("INSERT batch_single_test.users") + .spanName("INSERT batch_single_test.items") + .oldSpanName("INSERT batch_single_test.items") + .statement("INSERT INTO batch_single_test.items (id, num) values (?, ?)") + .oldStatement("INSERT INTO batch_single_test.items (id, num) values (?, ?)") + .summary("INSERT batch_single_test.items") .operation("INSERT") .oldOperation("INSERT") - .collection("batch_single_test.users") - .oldCollection("batch_single_test.users") + .collection("batch_single_test.items") + .oldCollection("batch_single_test.items") .build(), BatchScenario.builder("twoSameOperation") .keyspace("batch_same_test") .buildBatch( session -> { PreparedStatement insert = - session.prepare( - "INSERT INTO batch_same_test.users (name, age) values (?, ?)"); - return new BatchStatement() - .add(insert.bind("alice", 1)) - .add(insert.bind("bob", 2)); + session.prepare("INSERT INTO batch_same_test.items (id, num) values (?, ?)"); + return new BatchStatement().add(insert.bind(1, 1)).add(insert.bind(2, 2)); }) - .spanName("BATCH INSERT batch_same_test.users") + .spanName("BATCH INSERT batch_same_test.items") .oldSpanName("DB Query") - .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") - .summary("BATCH INSERT batch_same_test.users") + .statement("INSERT INTO batch_same_test.items (id, num) values (?, ?)") + .summary("BATCH INSERT batch_same_test.items") .operation("BATCH INSERT") - .collection("batch_same_test.users") + .collection("batch_same_test.items") .batchSize(2) .build(), BatchScenario.builder("twoDifferentOperations") @@ -418,21 +414,20 @@ private static Stream batchScenarios() { .buildBatch( session -> { PreparedStatement insert = - session.prepare( - "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); + session.prepare("INSERT INTO batch_mixed_test.items (id, num) values (4, ?)"); return new BatchStatement() - .add(insert.bind(1)) + .add(insert.bind(4)) .add( new SimpleStatement( - "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); + "UPDATE batch_mixed_test.items SET num = 5 WHERE id = 4")); }) .spanName("BATCH") .oldSpanName("DB Query") .statement( - "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") + "INSERT INTO batch_mixed_test.items (id, num) values (4, ?); UPDATE batch_mixed_test.items SET num = ? WHERE id = ?") .summary("BATCH") .operation("BATCH") - .collection("batch_mixed_test.users") + .collection("batch_mixed_test.items") .batchSize(2) .build()); } diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index acb5370aa37f..417f61eab697 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -203,8 +203,7 @@ void batchStatement(BatchScenario scenario) { "CREATE KEYSPACE " + scenario.keyspace + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); - session.execute( - "CREATE TABLE " + scenario.keyspace + ".users ( name text PRIMARY KEY, age int )"); + session.execute("CREATE TABLE " + scenario.keyspace + ".items ( id int PRIMARY KEY, num int )"); testing().waitForTraces(3); testing().clearData(); @@ -290,36 +289,34 @@ private static Stream batchScenarios() { session -> { PreparedStatement insert = session.prepare( - "INSERT INTO batch_single_test.users (name, age) values (?, ?)"); - return BatchStatement.newInstance( - DefaultBatchType.LOGGED, insert.bind("alice", 1)); + "INSERT INTO batch_single_test.items (id, num) values (?, ?)"); + return BatchStatement.newInstance(DefaultBatchType.LOGGED, insert.bind(1, 1)); }) - .spanName("INSERT batch_single_test.users") - .oldSpanName("INSERT batch_single_test.users") - .statement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") - .oldStatement("INSERT INTO batch_single_test.users (name, age) values (?, ?)") - .summary("INSERT batch_single_test.users") + .spanName("INSERT batch_single_test.items") + .oldSpanName("INSERT batch_single_test.items") + .statement("INSERT INTO batch_single_test.items (id, num) values (?, ?)") + .oldStatement("INSERT INTO batch_single_test.items (id, num) values (?, ?)") + .summary("INSERT batch_single_test.items") .operation("INSERT") .oldOperation("INSERT") - .collection("batch_single_test.users") - .oldCollection("batch_single_test.users") + .collection("batch_single_test.items") + .oldCollection("batch_single_test.items") .build(), BatchScenario.builder("twoSameOperation") .keyspace("batch_same_test") .buildBatch( session -> { PreparedStatement insert = - session.prepare( - "INSERT INTO batch_same_test.users (name, age) values (?, ?)"); + session.prepare("INSERT INTO batch_same_test.items (id, num) values (?, ?)"); return BatchStatement.newInstance( - DefaultBatchType.LOGGED, insert.bind("alice", 1), insert.bind("bob", 2)); + DefaultBatchType.LOGGED, insert.bind(1, 1), insert.bind(2, 2)); }) - .spanName("BATCH INSERT batch_same_test.users") + .spanName("BATCH INSERT batch_same_test.items") .oldSpanName("DB Query") - .statement("INSERT INTO batch_same_test.users (name, age) values (?, ?)") - .summary("BATCH INSERT batch_same_test.users") + .statement("INSERT INTO batch_same_test.items (id, num) values (?, ?)") + .summary("BATCH INSERT batch_same_test.items") .operation("BATCH INSERT") - .collection("batch_same_test.users") + .collection("batch_same_test.items") .batchSize(2) .build(), BatchScenario.builder("twoDifferentOperations") @@ -327,21 +324,20 @@ private static Stream batchScenarios() { .buildBatch( session -> { PreparedStatement insert = - session.prepare( - "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?)"); + session.prepare("INSERT INTO batch_mixed_test.items (id, num) values (4, ?)"); return BatchStatement.newInstance( DefaultBatchType.LOGGED, - insert.bind(1), + insert.bind(4), SimpleStatement.newInstance( - "UPDATE batch_mixed_test.users SET age = 2 WHERE name = 'alice'")); + "UPDATE batch_mixed_test.items SET num = 5 WHERE id = 4")); }) .spanName("BATCH") .oldSpanName("DB Query") .statement( - "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?") + "INSERT INTO batch_mixed_test.items (id, num) values (4, ?); UPDATE batch_mixed_test.items SET num = ? WHERE id = ?") .summary("BATCH") .operation("BATCH") - .collection("batch_mixed_test.users") + .collection("batch_mixed_test.items") .batchSize(2) .build()); } diff --git a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java index 27a3c27ce348..009dddbcef4e 100644 --- a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java +++ b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java @@ -1896,44 +1896,45 @@ static Stream batchCasesStream() { // statement and carries db.statement/db.operation/db.sql.table under old semconv BatchScenario.builder() .name("single") - .createTable("stmt_batch_single") - .addQuery("INSERT INTO stmt_batch_single VALUES(1)") + .createTable("items") + .addQuery("INSERT INTO items (id, num) VALUES (1, 1)") .expectedResult(1) - .spanName("INSERT stmt_batch_single") - .oldSpanName("INSERT " + DATABASE_NAME_LOWER + ".stmt_batch_single") - .queryText("INSERT INTO stmt_batch_single VALUES(?)") - .oldStatement("INSERT INTO stmt_batch_single VALUES(?)") - .summary("INSERT stmt_batch_single") + .spanName("INSERT items") + .oldSpanName("INSERT " + DATABASE_NAME_LOWER + ".items") + .queryText("INSERT INTO items (id, num) VALUES (?, ?)") + .oldStatement("INSERT INTO items (id, num) VALUES (?, ?)") + .summary("INSERT items") .oldOperation("INSERT") - .oldTable("stmt_batch_single") + .oldTable("items") .build(), // a multi-statement batch only emits db.query.text/summary and BATCH span name under // stable semconv; under old semconv it has no statement-level attributes and the span // name falls back to the namespace BatchScenario.builder() .name("twoSameOperation") - .createTable("stmt_batch_same") - .addQuery("INSERT INTO stmt_batch_same VALUES(1)") - .addQuery("INSERT INTO stmt_batch_same VALUES(2)") + .createTable("items") + .addQuery("INSERT INTO items (id, num) VALUES (2, 2)") + .addQuery("INSERT INTO items (id, num) VALUES (3, 3)") .expectedResult(1, 1) - .spanName("BATCH INSERT stmt_batch_same") + .spanName("BATCH INSERT items") .oldSpanName(DATABASE_NAME_LOWER) - .queryText("INSERT INTO stmt_batch_same VALUES(?)") - .summary("BATCH INSERT stmt_batch_same") + .queryText("INSERT INTO items (id, num) VALUES (?, ?)") + .summary("BATCH INSERT items") .batchSize(2) .build(), + // a multi-statement batch with different operations has no shared operation or summary, + // so db.query.summary (and the span name) is just BATCH; under old semconv it has no + // statement-level attributes and the span name falls back to the namespace BatchScenario.builder() .name("twoDifferentOperations") - .createTable("stmt_batch_diff_1") - .createTable("stmt_batch_diff_2") - .addQuery("INSERT INTO stmt_batch_diff_1 VALUES(1)") - .addQuery("INSERT INTO stmt_batch_diff_2 VALUES(2)") + .createTable("items") + .addQuery("INSERT INTO items (id, num) VALUES (4, 4)") + .addQuery("UPDATE items SET num = 5 WHERE id = 4") .expectedResult(1, 1) .spanName("BATCH") .oldSpanName(DATABASE_NAME_LOWER) .queryText( - "INSERT INTO stmt_batch_diff_1 VALUES(?); INSERT INTO stmt_batch_diff_2" - + " VALUES(?)") + "INSERT INTO items (id, num) VALUES (?, ?); UPDATE items SET num = ? WHERE id = ?") .summary("BATCH") .batchSize(2) .build()); @@ -1947,7 +1948,10 @@ void testStatementBatch(BatchScenario scenario) throws SQLException { for (String table : scenario.tablesToCreate) { Statement createTable = connection.createStatement(); - createTable.execute("CREATE TABLE " + table + " (id INTEGER not NULL, PRIMARY KEY ( id ))"); + createTable.execute( + "CREATE TABLE IF NOT EXISTS " + + table + + " (id INTEGER not NULL, num INTEGER, PRIMARY KEY ( id ))"); cleanup.deferCleanup(createTable); } if (!scenario.tablesToCreate.isEmpty()) { diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index 870c7ae3cb1f..7e38f90e0568 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -332,7 +332,7 @@ void batchQueries(BatchScenario scenario) { // db.query.summary and, under old semconv for a single-statement batch, db.sql.table); the // table must exist for the non-empty batches to execute successfully if (!scenario.queries.isEmpty()) { - createPlayersTable(connectionFactory); + createItemsTable(connectionFactory); getTesting().waitForTraces(1); getTesting().clearData(); } @@ -453,28 +453,32 @@ private static Stream batchScenarios() { // db.operation.batch.size and no BATCH prefix; under old semconv it carries the // statement, operation, collection and the operation+namespace+table span name BatchScenario.builder("single") - .queries(singletonList("INSERT INTO players VALUES (1)")) - .spanName("INSERT players") - .oldSpanName("INSERT " + DB + ".players") - .summary("INSERT players") - .queryText("INSERT INTO players VALUES (?)") - .oldStatement("INSERT INTO players VALUES (?)") + .queries(singletonList("INSERT INTO items (id, num) VALUES (1, 1)")) + .spanName("INSERT items") + .oldSpanName("INSERT " + DB + ".items") + .summary("INSERT items") + .queryText("INSERT INTO items (id, num) VALUES (?, ?)") + .oldStatement("INSERT INTO items (id, num) VALUES (?, ?)") .oldOperation("INSERT") - .oldCollection("players") + .oldCollection("items") .build(), // a multi-statement batch emits the BATCH span name, deduplicated db.query.text and // db.operation.batch.size under stable semconv; the collection name is captured in the - // summary (BATCH INSERT players). under old semconv the individual statements are + // summary (BATCH INSERT items). under old semconv the individual statements are // concatenated but the shared operation and collection are still captured BatchScenario.builder("twoSameOperation") - .queries(asList("INSERT INTO players VALUES (2)", "INSERT INTO players VALUES (3)")) - .spanName("BATCH INSERT players") - .oldSpanName("INSERT " + DB + ".players") - .summary("BATCH INSERT players") - .queryText("INSERT INTO players VALUES (?)") - .oldStatement("INSERT INTO players VALUES (?); INSERT INTO players VALUES (?)") + .queries( + asList( + "INSERT INTO items (id, num) VALUES (2, 2)", + "INSERT INTO items (id, num) VALUES (3, 3)")) + .spanName("BATCH INSERT items") + .oldSpanName("INSERT " + DB + ".items") + .summary("BATCH INSERT items") + .queryText("INSERT INTO items (id, num) VALUES (?, ?)") + .oldStatement( + "INSERT INTO items (id, num) VALUES (?, ?); INSERT INTO items (id, num) VALUES (?, ?)") .oldOperation("INSERT") - .oldCollection("players") + .oldCollection("items") .batchSize(2) .build(), // a multi-statement batch with different operations has no shared operation or summary, @@ -482,26 +486,30 @@ private static Stream batchScenarios() { // still concatenated into db.query.text / db.statement BatchScenario.builder("twoDifferentOperations") .queries( - asList("INSERT INTO players VALUES (4)", "UPDATE players SET id = 5 WHERE id = 4")) + asList( + "INSERT INTO items (id, num) VALUES (4, 4)", + "UPDATE items SET num = 5 WHERE id = 4")) .spanName("BATCH") - .oldSpanName("INSERT " + DB + ".players") + .oldSpanName("INSERT " + DB + ".items") .summary("BATCH") - .queryText("INSERT INTO players VALUES (?); UPDATE players SET id = ? WHERE id = ?") - .oldStatement("INSERT INTO players VALUES (?); UPDATE players SET id = ? WHERE id = ?") + .queryText( + "INSERT INTO items (id, num) VALUES (?, ?); UPDATE items SET num = ? WHERE id = ?") + .oldStatement( + "INSERT INTO items (id, num) VALUES (?, ?); UPDATE items SET num = ? WHERE id = ?") .oldOperation("INSERT") - .oldCollection("players") + .oldCollection("items") .batchSize(2) .build()); } - private void createPlayersTable(ConnectionFactory connectionFactory) { + private void createItemsTable(ConnectionFactory connectionFactory) { Mono.from(connectionFactory.create()) .flatMapMany( connection -> Mono.from( connection .createStatement( - "CREATE TABLE IF NOT EXISTS players (id INTEGER PRIMARY KEY)") + "CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, num INTEGER)") .execute()) .flatMapMany(result -> result.map((row, metadata) -> "")) .concatWith(Mono.from(connection.close()).cast(String.class))) From 78bb9ba32ae5dd4bf4d2f8ec3c21e1319e75f49d Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 16:22:40 -0700 Subject: [PATCH 17/31] Recreate a fresh items table per scenario in JDBC and R2DBC batch tests --- .../AbstractJdbcInstrumentationTest.java | 49 +++++++------------ .../v1_0/AbstractR2dbcStatementTest.java | 37 +++++++------- 2 files changed, 38 insertions(+), 48 deletions(-) diff --git a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java index 009dddbcef4e..26bc39ec5843 100644 --- a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java +++ b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java @@ -1879,10 +1879,11 @@ void testProxyPreparedStatement() throws SQLException { .hasParent(trace.getSpan(0)))); } - // describes the four batch cases: the tables to create, the statements added to the batch, the - // expected executeBatch() result, and the expected client span. batch telemetry comes from the - // shared SQL extractors and is database-agnostic, so a single (in-memory) database is enough to - // lock down its shape + // describes the four batch cases: the statements added to the batch, the expected + // executeBatch() result, and the expected client span. each scenario runs against a freshly + // recreated items table, so batch row ids can be reused across scenarios. batch telemetry comes + // from the shared SQL extractors and is database-agnostic, so a single (in-memory) database is + // enough to lock down its shape static Stream batchCasesStream() { return Stream.of( // an empty batch still produces a client span, but with no query text or batch size; @@ -1896,7 +1897,6 @@ static Stream batchCasesStream() { // statement and carries db.statement/db.operation/db.sql.table under old semconv BatchScenario.builder() .name("single") - .createTable("items") .addQuery("INSERT INTO items (id, num) VALUES (1, 1)") .expectedResult(1) .spanName("INSERT items") @@ -1912,9 +1912,8 @@ static Stream batchCasesStream() { // name falls back to the namespace BatchScenario.builder() .name("twoSameOperation") - .createTable("items") + .addQuery("INSERT INTO items (id, num) VALUES (1, 1)") .addQuery("INSERT INTO items (id, num) VALUES (2, 2)") - .addQuery("INSERT INTO items (id, num) VALUES (3, 3)") .expectedResult(1, 1) .spanName("BATCH INSERT items") .oldSpanName(DATABASE_NAME_LOWER) @@ -1927,9 +1926,8 @@ static Stream batchCasesStream() { // statement-level attributes and the span name falls back to the namespace BatchScenario.builder() .name("twoDifferentOperations") - .createTable("items") - .addQuery("INSERT INTO items (id, num) VALUES (4, 4)") - .addQuery("UPDATE items SET num = 5 WHERE id = 4") + .addQuery("INSERT INTO items (id, num) VALUES (1, 1)") + .addQuery("UPDATE items SET num = 5 WHERE id = 1") .expectedResult(1, 1) .spanName("BATCH") .oldSpanName(DATABASE_NAME_LOWER) @@ -1946,18 +1944,17 @@ void testStatementBatch(BatchScenario scenario) throws SQLException { Connection connection = wrap(new org.h2.Driver().connect(JDBC_URLS.get("h2"), null)); cleanup.deferCleanup(connection); - for (String table : scenario.tablesToCreate) { - Statement createTable = connection.createStatement(); - createTable.execute( - "CREATE TABLE IF NOT EXISTS " - + table - + " (id INTEGER not NULL, num INTEGER, PRIMARY KEY ( id ))"); - cleanup.deferCleanup(createTable); - } - if (!scenario.tablesToCreate.isEmpty()) { - testing().waitForTraces(scenario.tablesToCreate.size()); - testing().clearData(); - } + // recreate a fresh items table for each scenario so that batch row ids can be reused without + // worrying about collisions from previous scenarios + Statement dropTable = connection.createStatement(); + dropTable.execute("DROP TABLE IF EXISTS items"); + cleanup.deferCleanup(dropTable); + Statement createTable = connection.createStatement(); + createTable.execute( + "CREATE TABLE items (id INTEGER not NULL, num INTEGER, PRIMARY KEY ( id ))"); + cleanup.deferCleanup(createTable); + testing().waitForTraces(2); + testing().clearData(); Statement statement = connection.createStatement(); cleanup.deferCleanup(statement); @@ -2406,7 +2403,6 @@ void testStatementWrapper() throws SQLException { private static final class BatchScenario { final String name; - final List tablesToCreate; final List statementsToAdd; final int[] expectedResult; final String spanName; @@ -2420,7 +2416,6 @@ private static final class BatchScenario { BatchScenario(Builder builder) { this.name = builder.name; - this.tablesToCreate = builder.tablesToCreate; this.statementsToAdd = builder.statementsToAdd; this.expectedResult = builder.expectedResult; this.spanName = builder.spanName; @@ -2445,7 +2440,6 @@ static Builder builder() { static final class Builder { private String name; - private final List tablesToCreate = new ArrayList<>(); private final List statementsToAdd = new ArrayList<>(); private int[] expectedResult = new int[] {}; private String spanName; @@ -2462,11 +2456,6 @@ Builder name(String name) { return this; } - Builder createTable(String table) { - this.tablesToCreate.add(table); - return this; - } - Builder addQuery(String query) { this.statementsToAdd.add(query); return this; diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index 7e38f90e0568..9a3c12761191 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -328,14 +328,12 @@ void batchQueries(BatchScenario scenario) { .option(CONNECT_TIMEOUT, Duration.ofSeconds(30)) .build()); - // the batch statements target a real table so that the collection name is captured (in - // db.query.summary and, under old semconv for a single-statement batch, db.sql.table); the - // table must exist for the non-empty batches to execute successfully - if (!scenario.queries.isEmpty()) { - createItemsTable(connectionFactory); - getTesting().waitForTraces(1); - getTesting().clearData(); - } + // recreate a fresh items table for each scenario so that batch row ids can be reused without + // worrying about collisions from previous scenarios; the table also lets the collection name + // be captured (in db.query.summary and, under old semconv, db.sql.table) + recreateItemsTable(connectionFactory); + getTesting().waitForTraces(2); + getTesting().clearData(); Throwable thrown = catchThrowable( @@ -469,8 +467,8 @@ private static Stream batchScenarios() { BatchScenario.builder("twoSameOperation") .queries( asList( - "INSERT INTO items (id, num) VALUES (2, 2)", - "INSERT INTO items (id, num) VALUES (3, 3)")) + "INSERT INTO items (id, num) VALUES (1, 1)", + "INSERT INTO items (id, num) VALUES (2, 2)")) .spanName("BATCH INSERT items") .oldSpanName("INSERT " + DB + ".items") .summary("BATCH INSERT items") @@ -487,8 +485,8 @@ private static Stream batchScenarios() { BatchScenario.builder("twoDifferentOperations") .queries( asList( - "INSERT INTO items (id, num) VALUES (4, 4)", - "UPDATE items SET num = 5 WHERE id = 4")) + "INSERT INTO items (id, num) VALUES (1, 1)", + "UPDATE items SET num = 5 WHERE id = 1")) .spanName("BATCH") .oldSpanName("INSERT " + DB + ".items") .summary("BATCH") @@ -502,16 +500,19 @@ private static Stream batchScenarios() { .build()); } - private void createItemsTable(ConnectionFactory connectionFactory) { + private void recreateItemsTable(ConnectionFactory connectionFactory) { Mono.from(connectionFactory.create()) .flatMapMany( connection -> - Mono.from( - connection - .createStatement( - "CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, num INTEGER)") - .execute()) + Mono.from(connection.createStatement("DROP TABLE IF EXISTS items").execute()) .flatMapMany(result -> result.map((row, metadata) -> "")) + .concatWith( + Mono.from( + connection + .createStatement( + "CREATE TABLE items (id INTEGER PRIMARY KEY, num INTEGER)") + .execute()) + .flatMapMany(result -> result.map((row, metadata) -> ""))) .concatWith(Mono.from(connection.close()).cast(String.class))) .blockLast(Duration.ofMinutes(1)); } From 40c9ce95d0e74c2c364d1a9d0f3ab20c60f742fa Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 16:53:21 -0700 Subject: [PATCH 18/31] Rename batch test table to batch_test and align Vertx batch tests --- .../cassandra/v3_0/CassandraClientTest.java | 57 ++++++++---------- .../common/v4_0/AbstractCassandraTest.java | 57 ++++++++---------- .../AbstractJdbcInstrumentationTest.java | 44 +++++++------- .../v1_0/AbstractR2dbcStatementTest.java | 58 +++++++++---------- .../sqlclient/v4_0/VertxSqlClientTest.java | 39 +++++++++---- .../sqlclient/v5_0/VertxSqlClientTest.java | 39 +++++++++---- 6 files changed, 153 insertions(+), 141 deletions(-) diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index 6db46fc1b9ca..c46f9b474d6f 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -63,6 +63,10 @@ class CassandraClientTest { private static final ExecutorService executor = Executors.newCachedThreadPool(); + // all batch scenarios share a single keyspace + table (recreated per scenario), so batch row ids + // can be reused without worrying about collisions from previous scenarios + private static final String BATCH_KEYSPACE = "batch_test"; + @RegisterExtension static final InstrumentationExtension testing = AgentInstrumentationExtension.create(); @@ -307,12 +311,12 @@ void batchStatement(BatchScenario scenario) { Session session = cluster.connect(); cleanup.deferCleanup(session); - session.execute("DROP KEYSPACE IF EXISTS " + scenario.keyspace); + session.execute("DROP KEYSPACE IF EXISTS " + BATCH_KEYSPACE); session.execute( "CREATE KEYSPACE " - + scenario.keyspace + + BATCH_KEYSPACE + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); - session.execute("CREATE TABLE " + scenario.keyspace + ".items ( id int PRIMARY KEY, num int )"); + session.execute("CREATE TABLE " + BATCH_KEYSPACE + ".records ( id int PRIMARY KEY, num int )"); testing.waitForTraces(3); testing.clearData(); @@ -366,7 +370,6 @@ private static Stream batchScenarios() { // an empty batch still produces a client span, but with no query text, summary, // operation or batch size; the span name falls back to the database system name BatchScenario.builder("empty") - .keyspace("batch_empty_test") .buildBatch(session -> new BatchStatement()) .spanName("cassandra") .oldSpanName("DB Query") @@ -375,59 +378,55 @@ private static Stream batchScenarios() { // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no // db.operation.batch.size BatchScenario.builder("single") - .keyspace("batch_single_test") .buildBatch( session -> { PreparedStatement insert = - session.prepare( - "INSERT INTO batch_single_test.items (id, num) values (?, ?)"); + session.prepare("INSERT INTO batch_test.records (id, num) values (?, ?)"); return new BatchStatement().add(insert.bind(1, 1)); }) - .spanName("INSERT batch_single_test.items") - .oldSpanName("INSERT batch_single_test.items") - .statement("INSERT INTO batch_single_test.items (id, num) values (?, ?)") - .oldStatement("INSERT INTO batch_single_test.items (id, num) values (?, ?)") - .summary("INSERT batch_single_test.items") + .spanName("INSERT batch_test.records") + .oldSpanName("INSERT batch_test.records") + .statement("INSERT INTO batch_test.records (id, num) values (?, ?)") + .oldStatement("INSERT INTO batch_test.records (id, num) values (?, ?)") + .summary("INSERT batch_test.records") .operation("INSERT") .oldOperation("INSERT") - .collection("batch_single_test.items") - .oldCollection("batch_single_test.items") + .collection("batch_test.records") + .oldCollection("batch_test.records") .build(), BatchScenario.builder("twoSameOperation") - .keyspace("batch_same_test") .buildBatch( session -> { PreparedStatement insert = - session.prepare("INSERT INTO batch_same_test.items (id, num) values (?, ?)"); + session.prepare("INSERT INTO batch_test.records (id, num) values (?, ?)"); return new BatchStatement().add(insert.bind(1, 1)).add(insert.bind(2, 2)); }) - .spanName("BATCH INSERT batch_same_test.items") + .spanName("BATCH INSERT batch_test.records") .oldSpanName("DB Query") - .statement("INSERT INTO batch_same_test.items (id, num) values (?, ?)") - .summary("BATCH INSERT batch_same_test.items") + .statement("INSERT INTO batch_test.records (id, num) values (?, ?)") + .summary("BATCH INSERT batch_test.records") .operation("BATCH INSERT") - .collection("batch_same_test.items") + .collection("batch_test.records") .batchSize(2) .build(), BatchScenario.builder("twoDifferentOperations") - .keyspace("batch_mixed_test") .buildBatch( session -> { PreparedStatement insert = - session.prepare("INSERT INTO batch_mixed_test.items (id, num) values (4, ?)"); + session.prepare("INSERT INTO batch_test.records (id, num) values (4, ?)"); return new BatchStatement() .add(insert.bind(4)) .add( new SimpleStatement( - "UPDATE batch_mixed_test.items SET num = 5 WHERE id = 4")); + "UPDATE batch_test.records SET num = 5 WHERE id = 4")); }) .spanName("BATCH") .oldSpanName("DB Query") .statement( - "INSERT INTO batch_mixed_test.items (id, num) values (4, ?); UPDATE batch_mixed_test.items SET num = ? WHERE id = ?") + "INSERT INTO batch_test.records (id, num) values (4, ?); UPDATE batch_test.records SET num = ? WHERE id = ?") .summary("BATCH") .operation("BATCH") - .collection("batch_mixed_test.items") + .collection("batch_test.records") .batchSize(2) .build()); } @@ -566,7 +565,6 @@ private static class Parameter { private static final class BatchScenario { final String name; - final String keyspace; final Function buildBatch; final String spanName; final String oldSpanName; @@ -581,7 +579,6 @@ private static final class BatchScenario { BatchScenario(Builder builder) { this.name = builder.name; - this.keyspace = builder.keyspace; this.buildBatch = builder.buildBatch; this.spanName = builder.spanName; this.oldSpanName = builder.oldSpanName; @@ -607,7 +604,6 @@ static Builder builder(String name) { static final class Builder { private final String name; - private String keyspace; private Function buildBatch; private String spanName; private String oldSpanName; @@ -624,11 +620,6 @@ static final class Builder { this.name = name; } - Builder keyspace(String keyspace) { - this.keyspace = keyspace; - return this; - } - Builder buildBatch(Function buildBatch) { this.buildBatch = buildBatch; return this; diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index 417f61eab697..05083e7b8688 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -71,6 +71,10 @@ public abstract class AbstractCassandraTest { private static final Logger logger = LoggerFactory.getLogger(AbstractCassandraTest.class); + // all batch scenarios share a single keyspace + table (recreated per scenario), so batch row ids + // can be reused without worrying about collisions from previous scenarios + private static final String BATCH_KEYSPACE = "batch_test"; + @RegisterExtension protected final AutoCleanupExtension cleanup = AutoCleanupExtension.create(); private GenericContainer cassandra; @@ -198,12 +202,12 @@ void batchStatement(BatchScenario scenario) { CqlSession session = getSession(null); cleanup.deferCleanup(session); - session.execute("DROP KEYSPACE IF EXISTS " + scenario.keyspace); + session.execute("DROP KEYSPACE IF EXISTS " + BATCH_KEYSPACE); session.execute( "CREATE KEYSPACE " - + scenario.keyspace + + BATCH_KEYSPACE + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); - session.execute("CREATE TABLE " + scenario.keyspace + ".items ( id int PRIMARY KEY, num int )"); + session.execute("CREATE TABLE " + BATCH_KEYSPACE + ".records ( id int PRIMARY KEY, num int )"); testing().waitForTraces(3); testing().clearData(); @@ -275,7 +279,6 @@ private static Stream batchScenarios() { // an empty batch still produces a client span, but with no query text, summary, // operation or batch size; the span name falls back to the database system name BatchScenario.builder("empty") - .keyspace("batch_empty_test") .buildBatch(session -> BatchStatement.newInstance(DefaultBatchType.LOGGED)) .spanName("cassandra") .oldSpanName("DB Query") @@ -284,60 +287,56 @@ private static Stream batchScenarios() { // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no // db.operation.batch.size BatchScenario.builder("single") - .keyspace("batch_single_test") .buildBatch( session -> { PreparedStatement insert = - session.prepare( - "INSERT INTO batch_single_test.items (id, num) values (?, ?)"); + session.prepare("INSERT INTO batch_test.records (id, num) values (?, ?)"); return BatchStatement.newInstance(DefaultBatchType.LOGGED, insert.bind(1, 1)); }) - .spanName("INSERT batch_single_test.items") - .oldSpanName("INSERT batch_single_test.items") - .statement("INSERT INTO batch_single_test.items (id, num) values (?, ?)") - .oldStatement("INSERT INTO batch_single_test.items (id, num) values (?, ?)") - .summary("INSERT batch_single_test.items") + .spanName("INSERT batch_test.records") + .oldSpanName("INSERT batch_test.records") + .statement("INSERT INTO batch_test.records (id, num) values (?, ?)") + .oldStatement("INSERT INTO batch_test.records (id, num) values (?, ?)") + .summary("INSERT batch_test.records") .operation("INSERT") .oldOperation("INSERT") - .collection("batch_single_test.items") - .oldCollection("batch_single_test.items") + .collection("batch_test.records") + .oldCollection("batch_test.records") .build(), BatchScenario.builder("twoSameOperation") - .keyspace("batch_same_test") .buildBatch( session -> { PreparedStatement insert = - session.prepare("INSERT INTO batch_same_test.items (id, num) values (?, ?)"); + session.prepare("INSERT INTO batch_test.records (id, num) values (?, ?)"); return BatchStatement.newInstance( DefaultBatchType.LOGGED, insert.bind(1, 1), insert.bind(2, 2)); }) - .spanName("BATCH INSERT batch_same_test.items") + .spanName("BATCH INSERT batch_test.records") .oldSpanName("DB Query") - .statement("INSERT INTO batch_same_test.items (id, num) values (?, ?)") - .summary("BATCH INSERT batch_same_test.items") + .statement("INSERT INTO batch_test.records (id, num) values (?, ?)") + .summary("BATCH INSERT batch_test.records") .operation("BATCH INSERT") - .collection("batch_same_test.items") + .collection("batch_test.records") .batchSize(2) .build(), BatchScenario.builder("twoDifferentOperations") - .keyspace("batch_mixed_test") .buildBatch( session -> { PreparedStatement insert = - session.prepare("INSERT INTO batch_mixed_test.items (id, num) values (4, ?)"); + session.prepare("INSERT INTO batch_test.records (id, num) values (4, ?)"); return BatchStatement.newInstance( DefaultBatchType.LOGGED, insert.bind(4), SimpleStatement.newInstance( - "UPDATE batch_mixed_test.items SET num = 5 WHERE id = 4")); + "UPDATE batch_test.records SET num = 5 WHERE id = 4")); }) .spanName("BATCH") .oldSpanName("DB Query") .statement( - "INSERT INTO batch_mixed_test.items (id, num) values (4, ?); UPDATE batch_mixed_test.items SET num = ? WHERE id = ?") + "INSERT INTO batch_test.records (id, num) values (4, ?); UPDATE batch_test.records SET num = ? WHERE id = ?") .summary("BATCH") .operation("BATCH") - .collection("batch_mixed_test.items") + .collection("batch_test.records") .batchSize(2) .build()); } @@ -600,7 +599,6 @@ protected CqlSessionBuilder addContactPoint(CqlSessionBuilder sessionBuilder) { private static final class BatchScenario { final String name; - final String keyspace; final Function buildBatch; final String spanName; final String oldSpanName; @@ -615,7 +613,6 @@ private static final class BatchScenario { BatchScenario(Builder builder) { this.name = builder.name; - this.keyspace = builder.keyspace; this.buildBatch = builder.buildBatch; this.spanName = builder.spanName; this.oldSpanName = builder.oldSpanName; @@ -641,7 +638,6 @@ static Builder builder(String name) { static final class Builder { private final String name; - private String keyspace; private Function buildBatch; private String spanName; private String oldSpanName; @@ -658,11 +654,6 @@ static final class Builder { this.name = name; } - Builder keyspace(String keyspace) { - this.keyspace = keyspace; - return this; - } - Builder buildBatch(Function buildBatch) { this.buildBatch = buildBatch; return this; diff --git a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java index 26bc39ec5843..be668c8384b1 100644 --- a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java +++ b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java @@ -1881,9 +1881,9 @@ void testProxyPreparedStatement() throws SQLException { // describes the four batch cases: the statements added to the batch, the expected // executeBatch() result, and the expected client span. each scenario runs against a freshly - // recreated items table, so batch row ids can be reused across scenarios. batch telemetry comes - // from the shared SQL extractors and is database-agnostic, so a single (in-memory) database is - // enough to lock down its shape + // recreated batch_test table, so batch row ids can be reused across scenarios. batch telemetry + // comes from the shared SQL extractors and is database-agnostic, so a single (in-memory) database + // is enough to lock down its shape static Stream batchCasesStream() { return Stream.of( // an empty batch still produces a client span, but with no query text or batch size; @@ -1897,28 +1897,28 @@ static Stream batchCasesStream() { // statement and carries db.statement/db.operation/db.sql.table under old semconv BatchScenario.builder() .name("single") - .addQuery("INSERT INTO items (id, num) VALUES (1, 1)") + .addQuery("INSERT INTO batch_test (id, num) VALUES (1, 1)") .expectedResult(1) - .spanName("INSERT items") - .oldSpanName("INSERT " + DATABASE_NAME_LOWER + ".items") - .queryText("INSERT INTO items (id, num) VALUES (?, ?)") - .oldStatement("INSERT INTO items (id, num) VALUES (?, ?)") - .summary("INSERT items") + .spanName("INSERT batch_test") + .oldSpanName("INSERT " + DATABASE_NAME_LOWER + ".batch_test") + .queryText("INSERT INTO batch_test (id, num) VALUES (?, ?)") + .oldStatement("INSERT INTO batch_test (id, num) VALUES (?, ?)") + .summary("INSERT batch_test") .oldOperation("INSERT") - .oldTable("items") + .oldTable("batch_test") .build(), // a multi-statement batch only emits db.query.text/summary and BATCH span name under // stable semconv; under old semconv it has no statement-level attributes and the span // name falls back to the namespace BatchScenario.builder() .name("twoSameOperation") - .addQuery("INSERT INTO items (id, num) VALUES (1, 1)") - .addQuery("INSERT INTO items (id, num) VALUES (2, 2)") + .addQuery("INSERT INTO batch_test (id, num) VALUES (1, 1)") + .addQuery("INSERT INTO batch_test (id, num) VALUES (2, 2)") .expectedResult(1, 1) - .spanName("BATCH INSERT items") + .spanName("BATCH INSERT batch_test") .oldSpanName(DATABASE_NAME_LOWER) - .queryText("INSERT INTO items (id, num) VALUES (?, ?)") - .summary("BATCH INSERT items") + .queryText("INSERT INTO batch_test (id, num) VALUES (?, ?)") + .summary("BATCH INSERT batch_test") .batchSize(2) .build(), // a multi-statement batch with different operations has no shared operation or summary, @@ -1926,13 +1926,13 @@ static Stream batchCasesStream() { // statement-level attributes and the span name falls back to the namespace BatchScenario.builder() .name("twoDifferentOperations") - .addQuery("INSERT INTO items (id, num) VALUES (1, 1)") - .addQuery("UPDATE items SET num = 5 WHERE id = 1") + .addQuery("INSERT INTO batch_test (id, num) VALUES (1, 1)") + .addQuery("UPDATE batch_test SET num = 5 WHERE id = 1") .expectedResult(1, 1) .spanName("BATCH") .oldSpanName(DATABASE_NAME_LOWER) .queryText( - "INSERT INTO items (id, num) VALUES (?, ?); UPDATE items SET num = ? WHERE id = ?") + "INSERT INTO batch_test (id, num) VALUES (?, ?); UPDATE batch_test SET num = ? WHERE id = ?") .summary("BATCH") .batchSize(2) .build()); @@ -1944,14 +1944,14 @@ void testStatementBatch(BatchScenario scenario) throws SQLException { Connection connection = wrap(new org.h2.Driver().connect(JDBC_URLS.get("h2"), null)); cleanup.deferCleanup(connection); - // recreate a fresh items table for each scenario so that batch row ids can be reused without - // worrying about collisions from previous scenarios + // recreate a fresh batch_test table for each scenario so that batch row ids can be reused + // without worrying about collisions from previous scenarios Statement dropTable = connection.createStatement(); - dropTable.execute("DROP TABLE IF EXISTS items"); + dropTable.execute("DROP TABLE IF EXISTS batch_test"); cleanup.deferCleanup(dropTable); Statement createTable = connection.createStatement(); createTable.execute( - "CREATE TABLE items (id INTEGER not NULL, num INTEGER, PRIMARY KEY ( id ))"); + "CREATE TABLE batch_test (id INTEGER not NULL, num INTEGER, PRIMARY KEY ( id ))"); cleanup.deferCleanup(createTable); testing().waitForTraces(2); testing().clearData(); diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index 9a3c12761191..d79a628fbe39 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -328,10 +328,10 @@ void batchQueries(BatchScenario scenario) { .option(CONNECT_TIMEOUT, Duration.ofSeconds(30)) .build()); - // recreate a fresh items table for each scenario so that batch row ids can be reused without - // worrying about collisions from previous scenarios; the table also lets the collection name - // be captured (in db.query.summary and, under old semconv, db.sql.table) - recreateItemsTable(connectionFactory); + // recreate a fresh batch_test table for each scenario so that batch row ids can be reused + // without worrying about collisions from previous scenarios; the table also lets the collection + // name be captured (in db.query.summary and, under old semconv, db.sql.table) + recreateBatchTestTable(connectionFactory); getTesting().waitForTraces(2); getTesting().clearData(); @@ -451,32 +451,32 @@ private static Stream batchScenarios() { // db.operation.batch.size and no BATCH prefix; under old semconv it carries the // statement, operation, collection and the operation+namespace+table span name BatchScenario.builder("single") - .queries(singletonList("INSERT INTO items (id, num) VALUES (1, 1)")) - .spanName("INSERT items") - .oldSpanName("INSERT " + DB + ".items") - .summary("INSERT items") - .queryText("INSERT INTO items (id, num) VALUES (?, ?)") - .oldStatement("INSERT INTO items (id, num) VALUES (?, ?)") + .queries(singletonList("INSERT INTO batch_test (id, num) VALUES (1, 1)")) + .spanName("INSERT batch_test") + .oldSpanName("INSERT " + DB + ".batch_test") + .summary("INSERT batch_test") + .queryText("INSERT INTO batch_test (id, num) VALUES (?, ?)") + .oldStatement("INSERT INTO batch_test (id, num) VALUES (?, ?)") .oldOperation("INSERT") - .oldCollection("items") + .oldCollection("batch_test") .build(), // a multi-statement batch emits the BATCH span name, deduplicated db.query.text and // db.operation.batch.size under stable semconv; the collection name is captured in the - // summary (BATCH INSERT items). under old semconv the individual statements are + // summary (BATCH INSERT batch_test). under old semconv the individual statements are // concatenated but the shared operation and collection are still captured BatchScenario.builder("twoSameOperation") .queries( asList( - "INSERT INTO items (id, num) VALUES (1, 1)", - "INSERT INTO items (id, num) VALUES (2, 2)")) - .spanName("BATCH INSERT items") - .oldSpanName("INSERT " + DB + ".items") - .summary("BATCH INSERT items") - .queryText("INSERT INTO items (id, num) VALUES (?, ?)") + "INSERT INTO batch_test (id, num) VALUES (1, 1)", + "INSERT INTO batch_test (id, num) VALUES (2, 2)")) + .spanName("BATCH INSERT batch_test") + .oldSpanName("INSERT " + DB + ".batch_test") + .summary("BATCH INSERT batch_test") + .queryText("INSERT INTO batch_test (id, num) VALUES (?, ?)") .oldStatement( - "INSERT INTO items (id, num) VALUES (?, ?); INSERT INTO items (id, num) VALUES (?, ?)") + "INSERT INTO batch_test (id, num) VALUES (?, ?); INSERT INTO batch_test (id, num) VALUES (?, ?)") .oldOperation("INSERT") - .oldCollection("items") + .oldCollection("batch_test") .batchSize(2) .build(), // a multi-statement batch with different operations has no shared operation or summary, @@ -485,32 +485,32 @@ private static Stream batchScenarios() { BatchScenario.builder("twoDifferentOperations") .queries( asList( - "INSERT INTO items (id, num) VALUES (1, 1)", - "UPDATE items SET num = 5 WHERE id = 1")) + "INSERT INTO batch_test (id, num) VALUES (1, 1)", + "UPDATE batch_test SET num = 5 WHERE id = 1")) .spanName("BATCH") - .oldSpanName("INSERT " + DB + ".items") + .oldSpanName("INSERT " + DB + ".batch_test") .summary("BATCH") .queryText( - "INSERT INTO items (id, num) VALUES (?, ?); UPDATE items SET num = ? WHERE id = ?") + "INSERT INTO batch_test (id, num) VALUES (?, ?); UPDATE batch_test SET num = ? WHERE id = ?") .oldStatement( - "INSERT INTO items (id, num) VALUES (?, ?); UPDATE items SET num = ? WHERE id = ?") + "INSERT INTO batch_test (id, num) VALUES (?, ?); UPDATE batch_test SET num = ? WHERE id = ?") .oldOperation("INSERT") - .oldCollection("items") + .oldCollection("batch_test") .batchSize(2) .build()); } - private void recreateItemsTable(ConnectionFactory connectionFactory) { + private void recreateBatchTestTable(ConnectionFactory connectionFactory) { Mono.from(connectionFactory.create()) .flatMapMany( connection -> - Mono.from(connection.createStatement("DROP TABLE IF EXISTS items").execute()) + Mono.from(connection.createStatement("DROP TABLE IF EXISTS batch_test").execute()) .flatMapMany(result -> result.map((row, metadata) -> "")) .concatWith( Mono.from( connection .createStatement( - "CREATE TABLE items (id INTEGER PRIMARY KEY, num INTEGER)") + "CREATE TABLE batch_test (id INTEGER PRIMARY KEY, num INTEGER)") .execute()) .flatMapMany(result -> result.map((row, metadata) -> ""))) .concatWith(Mono.from(connection.close()).cast(String.class))) diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java index 7022c46d6559..f2eede47b02d 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java @@ -292,13 +292,19 @@ private static void assertPreparedSelect() { @ParameterizedTest @MethodSource("batchScenarios") void testBatch(BatchScenario scenario) throws Exception { + // recreate a fresh batch_test table for each scenario so that batch row ids can be reused + // without worrying about collisions from previous scenarios + recreateBatchTestTable(); + testing.waitForTraces(2); + testing.clearData(); + // an empty batch is rejected before sending, so its execution fails; non-empty batches succeed try { testing .runWithSpan( "parent", () -> - pool.preparedQuery("insert into test values ($1, $2) returning *") + pool.preparedQuery("insert into batch_test values ($1, $2) returning *") .executeBatch(scenario.tuples)) .toCompletionStage() .toCompletableFuture() @@ -315,7 +321,7 @@ void testBatch(BatchScenario scenario) throws Exception { span.hasName( emitStableDatabaseSemconv() ? scenario.stableSpanName - : "INSERT tempdb.test") + : "INSERT tempdb.batch_test") .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( @@ -326,7 +332,7 @@ void testBatch(BatchScenario scenario) throws Exception { equalTo(DB_USER, emitStableDatabaseSemconv() ? null : USER_DB), equalTo( maybeStable(DB_STATEMENT), - "insert into test values ($1, $2) returning *"), + "insert into batch_test values ($1, $2) returning *"), equalTo( DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? scenario.stableSummary : null), @@ -338,7 +344,7 @@ void testBatch(BatchScenario scenario) throws Exception { emitStableDatabaseSemconv() ? null : "INSERT"), equalTo( maybeStable(DB_SQL_TABLE), - emitStableDatabaseSemconv() ? null : "test"), + emitStableDatabaseSemconv() ? null : "batch_test"), equalTo( ERROR_TYPE, emitStableDatabaseSemconv() ? scenario.errorType : null), @@ -347,27 +353,36 @@ void testBatch(BatchScenario scenario) throws Exception { equalTo(SERVER_PORT, port)))); } + private static void recreateBatchTestTable() throws Exception { + pool.query("drop table if exists batch_test") + .execute() + .compose(r -> pool.query("create table batch_test(id int primary key, num int)").execute()) + .toCompletionStage() + .toCompletableFuture() + .get(30, SECONDS); + } + private static Stream batchScenarios() { return Stream.of( // an empty batch is rejected before sending, so it looks like a single statement but // records the error and emits no db.operation.batch.size BatchScenario.builder("empty") .tuples(emptyList()) - .stableSpanName("insert test") - .stableSummary("insert test") + .stableSpanName("insert batch_test") + .stableSummary("insert batch_test") .errorType("io.vertx.core.impl.NoStackTraceThrowable") .build(), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix BatchScenario.builder("single") - .tuples(singletonList(Tuple.of(3, "Three"))) - .stableSpanName("insert test") - .stableSummary("insert test") + .tuples(singletonList(Tuple.of(1, 1))) + .stableSpanName("insert batch_test") + .stableSummary("insert batch_test") .build(), BatchScenario.builder("twoSameOperation") - .tuples(asList(Tuple.of(4, "Four"), Tuple.of(5, "Five"))) - .stableSpanName("BATCH insert test") - .stableSummary("BATCH insert test") + .tuples(asList(Tuple.of(1, 1), Tuple.of(2, 2))) + .stableSpanName("BATCH insert batch_test") + .stableSummary("BATCH insert batch_test") .batchSize(2) .build()); } diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java index 2ad734b0ccc3..53eca7149e7e 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java @@ -293,13 +293,19 @@ private static void assertPreparedSelect() { @ParameterizedTest @MethodSource("batchScenarios") void testBatch(BatchScenario scenario) throws Exception { + // recreate a fresh batch_test table for each scenario so that batch row ids can be reused + // without worrying about collisions from previous scenarios + recreateBatchTestTable(); + testing.waitForTraces(2); + testing.clearData(); + // an empty batch is rejected before sending, so its execution fails; non-empty batches succeed try { testing .runWithSpan( "parent", () -> - pool.preparedQuery("insert into test values ($1, $2) returning *") + pool.preparedQuery("insert into batch_test values ($1, $2) returning *") .executeBatch(scenario.tuples)) .toCompletionStage() .toCompletableFuture() @@ -316,7 +322,7 @@ void testBatch(BatchScenario scenario) throws Exception { span.hasName( emitStableDatabaseSemconv() ? scenario.stableSpanName - : "INSERT tempdb.test") + : "INSERT tempdb.batch_test") .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( @@ -327,7 +333,7 @@ void testBatch(BatchScenario scenario) throws Exception { equalTo(DB_USER, emitStableDatabaseSemconv() ? null : USER_DB), equalTo( maybeStable(DB_STATEMENT), - "insert into test values ($1, $2) returning *"), + "insert into batch_test values ($1, $2) returning *"), equalTo( DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? scenario.stableSummary : null), @@ -339,7 +345,7 @@ void testBatch(BatchScenario scenario) throws Exception { emitStableDatabaseSemconv() ? null : "INSERT"), equalTo( maybeStable(DB_SQL_TABLE), - emitStableDatabaseSemconv() ? null : "test"), + emitStableDatabaseSemconv() ? null : "batch_test"), equalTo( ERROR_TYPE, emitStableDatabaseSemconv() ? scenario.errorType : null), @@ -348,27 +354,36 @@ void testBatch(BatchScenario scenario) throws Exception { equalTo(SERVER_PORT, port)))); } + private static void recreateBatchTestTable() throws Exception { + pool.query("drop table if exists batch_test") + .execute() + .compose(r -> pool.query("create table batch_test(id int primary key, num int)").execute()) + .toCompletionStage() + .toCompletableFuture() + .get(30, SECONDS); + } + private static Stream batchScenarios() { return Stream.of( // an empty batch is rejected before sending, so it looks like a single statement but // records the error and emits no db.operation.batch.size BatchScenario.builder("empty") .tuples(emptyList()) - .stableSpanName("insert test") - .stableSummary("insert test") + .stableSpanName("insert batch_test") + .stableSummary("insert batch_test") .errorType("io.vertx.core.VertxException") .build(), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix BatchScenario.builder("single") - .tuples(singletonList(Tuple.of(3, "Three"))) - .stableSpanName("insert test") - .stableSummary("insert test") + .tuples(singletonList(Tuple.of(1, 1))) + .stableSpanName("insert batch_test") + .stableSummary("insert batch_test") .build(), BatchScenario.builder("twoSameOperation") - .tuples(asList(Tuple.of(4, "Four"), Tuple.of(5, "Five"))) - .stableSpanName("BATCH insert test") - .stableSummary("BATCH insert test") + .tuples(asList(Tuple.of(1, 1), Tuple.of(2, 2))) + .stableSpanName("BATCH insert batch_test") + .stableSummary("BATCH insert batch_test") .batchSize(2) .build()); } From 044bae2e2f99a965279f63a775a7a0bb6e29a8b3 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 19:43:34 -0700 Subject: [PATCH 19/31] Add a mixed put+delete batch scenario to the DynamoDB batch tests --- .../v1_11/AbstractDynamoDbClientTest.java | 26 +++++++++++ .../v2_2/AbstractAws2ClientCoreTest.java | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java index 8ec04fd5a136..5b086d3d2969 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java @@ -31,6 +31,7 @@ import com.amazonaws.services.dynamodbv2.model.BatchGetItemRequest; import com.amazonaws.services.dynamodbv2.model.BatchWriteItemRequest; import com.amazonaws.services.dynamodbv2.model.CreateTableRequest; +import com.amazonaws.services.dynamodbv2.model.DeleteRequest; import com.amazonaws.services.dynamodbv2.model.KeysAndAttributes; import com.amazonaws.services.dynamodbv2.model.PutRequest; import com.amazonaws.services.dynamodbv2.model.WriteRequest; @@ -160,6 +161,17 @@ private static Stream batchScenarios() { .stableOperation("BATCH WriteItem") .batchSize(2) .hasCollection() + .build(), + // a batch mixing a put and a delete in one table: unlike the SQL/Cassandra matrices where + // a batch with differing operations collapses to just "BATCH", DynamoDB derives the + // operation name from the item count alone, so a put+delete write batch still reports the + // shared "BATCH WriteItem" operation (and the single collection) + BatchScenario.builder("writeItemMixed") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(mixedWriteItemRequest())) + .stableOperation("BATCH WriteItem") + .batchSize(2) + .hasCollection() .build()); } @@ -192,6 +204,20 @@ private static WriteRequest writeRequest(String value) { new PutRequest().withItem(singletonMap("key", new AttributeValue().withS(value)))); } + private static BatchWriteItemRequest mixedWriteItemRequest() { + List writes = + asList( + new WriteRequest() + .withPutRequest( + new PutRequest() + .withItem(singletonMap("key", new AttributeValue().withS("value")))), + new WriteRequest() + .withDeleteRequest( + new DeleteRequest() + .withKey(singletonMap("key", new AttributeValue().withS("anotherValue"))))); + return new BatchWriteItemRequest().withRequestItems(singletonMap("sometable", writes)); + } + private static final class BatchScenario { final String name; final String awsOperation; diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java index f958abbd48ba..f9c92b6ba68e 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java @@ -80,6 +80,7 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; +import software.amazon.awssdk.services.dynamodb.model.DeleteRequest; import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; @@ -700,6 +701,50 @@ private static Stream batchScenarios() { .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") .assertMetric() + .build(), + // a batch mixing a put and a delete in one table: unlike the SQL/Cassandra matrices where + // a batch with differing operations collapses to just "BATCH", DynamoDB derives the + // operation name from the item count alone, so a put+delete write batch still reports the + // shared "BATCH WriteItem" operation (and the single collection) + BatchScenario.builder("writeItemMixed") + .awsOperation("BatchWriteItem") + .responseContent(getResponseContent("BatchWriteItem")) + .execute( + c -> + c.batchWriteItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + asList( + WriteRequest.builder() + .putRequest( + PutRequest.builder() + .item( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build())) + .build()) + .build(), + WriteRequest.builder() + .deleteRequest( + DeleteRequest.builder() + .key( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("anotherValue") + .build())) + .build()) + .build()))))) + .stableOperation("BATCH WriteItem") + .hasCollection() + .batchSize(2) + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() .build()); } From 95e754494bdc3fbedbebd87034d3a87146880e55 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 20:51:36 -0700 Subject: [PATCH 20/31] Emit db.operation.batch.size=0 for empty batches across all batch instrumentations db.operation.batch.size is now captured for every batch execution including empty batches (size 0). It is only omitted when the batch size is 1, which is reported as a non-batch operation. This change applies uniformly to JDBC, R2DBC, Cassandra (3.0/4.0/4.4), Vert.x SQL client (4.0/5.0), and AWS DynamoDB SDK (1.11/2.2). Key changes: - Core gate in SqlClientAttributesExtractor/DbClientAttributesExtractor changed from batchSize > 1 to batchSize != 1 - JDBC javaagent tracks empty batches to report size 0 (option B) - R2DBC uses ExecutionType.BATCH to distinguish from Statement executions - Vert.x batch size computed for size != 1 - DynamoDB extractors emit 0 for empty BatchGetItem/BatchWriteItem - All test matrices updated to assert batchSize(0) on empty scenarios --- .../db/DbClientAttributesExtractor.java | 5 ++++- .../db/SqlClientAttributesExtractor.java | 5 ++++- .../internal/DynamoDbAttributesExtractor.java | 11 ++++++---- .../v1_11/AbstractDynamoDbClientTest.java | 6 ++++-- .../internal/DynamoDbAttributesExtractor.java | 8 ++++--- .../v2_2/AbstractAws2ClientCoreTest.java | 6 ++++-- .../cassandra/v3_0/CassandraClientTest.java | 5 +++-- .../common/v4_0/AbstractCassandraTest.java | 5 +++-- .../instrumentation/jdbc/JdbcAdviceScope.java | 6 ++++-- .../AbstractJdbcInstrumentationTest.java | 5 +++-- .../r2dbc/v1_0/internal/DbExecution.java | 8 ++++++- .../r2dbc/v1_0/DbExecutionTest.java | 21 +++++++++++++++++++ .../v1_0/AbstractR2dbcStatementTest.java | 11 ++++++---- .../v4_0/QueryExecutorInstrumentation.java | 4 +++- .../sqlclient/v4_0/VertxSqlClientTest.java | 3 ++- .../v5_0/QueryExecutorInstrumentation.java | 4 +++- .../sqlclient/v5_0/VertxSqlClientTest.java | 3 ++- 17 files changed, 86 insertions(+), 30 deletions(-) diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientAttributesExtractor.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientAttributesExtractor.java index e2c5913b00c9..f2a28b2217c8 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientAttributesExtractor.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientAttributesExtractor.java @@ -95,6 +95,9 @@ static void onStartCommon( boolean captureQueryParameters) { Long batchSize = getter.getDbOperationBatchSize(request); boolean isBatch = batchSize != null && batchSize > 1; + // db.operation.batch.size is captured for every batch execution (including an empty batch with + // size 0); it is only omitted for a single-statement batch, which is reported as a non-batch + boolean emitBatchSize = batchSize != null && batchSize != 1; if (emitStableDatabaseSemconv()) { attributes.put( @@ -104,7 +107,7 @@ static void onStartCommon( attributes.put(DB_QUERY_TEXT, getter.getDbQueryText(request)); attributes.put(DB_OPERATION_NAME, getter.getDbOperationName(request)); attributes.put(DB_QUERY_SUMMARY, getter.getDbQuerySummary(request)); - if (isBatch) { + if (emitBatchSize) { attributes.put(DB_OPERATION_BATCH_SIZE, batchSize); } } diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java index 0db9f091b271..ecfee247671c 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java @@ -102,6 +102,9 @@ public void onStart(AttributesBuilder attributes, Context parentContext, REQUEST Long batchSize = getter.getDbOperationBatchSize(request); boolean isBatch = batchSize != null && batchSize > 1; + // db.operation.batch.size is captured for every batch execution (including an empty batch with + // size 0); it is only omitted for a single-statement batch, which is reported as a non-batch + boolean emitBatchSize = batchSize != null && batchSize != 1; if (emitOldDatabaseSemconv()) { if (rawQueryTexts.size() == 1) { // for backcompat(?) @@ -118,7 +121,7 @@ public void onStart(AttributesBuilder attributes, Context parentContext, REQUEST } if (emitStableDatabaseSemconv()) { - if (isBatch) { + if (emitBatchSize) { attributes.put(DB_OPERATION_BATCH_SIZE, batchSize); } if (rawQueryTexts.size() == 1) { diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java index 3a0694ad74a6..ce9fb4a63904 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java @@ -51,7 +51,7 @@ public void onStart(AttributesBuilder attributes, Context parentContext, Request Long batchSize = extractBatchSize(operation, request.getOriginalRequest()); if (emitStableDatabaseSemconv()) { attributes.put(DB_OPERATION_NAME, getStableOperationName(operation, batchSize)); - if (isBatch(batchSize)) { + if (shouldEmitBatchSize(batchSize)) { attributes.put(DB_OPERATION_BATCH_SIZE, batchSize); } } @@ -132,7 +132,8 @@ private static Long extractBatchSize(@Nullable String operation, Object request) "BatchGetItem".equals(operation) ? countBatchGetItems(requestItems) : countBatchWriteItems(requestItems); - return batchSize == 0 ? null : batchSize; + // return the size for every batch request, including an empty batch with size 0 + return batchSize; } private static long countBatchGetItems(Map requestItems) { @@ -156,8 +157,10 @@ private static long countBatchWriteItems(Map requestItems) { return count; } - private static boolean isBatch(@Nullable Long batchSize) { - return batchSize != null && batchSize > 1; + // db.operation.batch.size is captured for every batch request (including an empty batch with + // size 0); it is only omitted for a single-item batch, which is reported as a non-batch operation + private static boolean shouldEmitBatchSize(@Nullable Long batchSize) { + return batchSize != null && batchSize != 1; } @Nullable diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java index 5b086d3d2969..84d0f12cc6fa 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java @@ -123,12 +123,13 @@ void batchOperation(BatchScenario scenario) throws ReflectiveOperationException private static Stream batchScenarios() { return Stream.of( - // an empty batch keeps the raw batch operation name and emits no batch size or - // collection name + // an empty batch keeps the raw batch operation name and carries db.operation.batch.size 0, + // but no collection name BatchScenario.builder("getItemEmpty") .awsOperation("BatchGetItem") .execute(client -> client.batchGetItem(getItemRequest(0))) .stableOperation("BatchGetItem") + .batchSize(0) .build(), // a single-item batch is not a batch, so it uses the singular item operation BatchScenario.builder("getItemSingle") @@ -148,6 +149,7 @@ private static Stream batchScenarios() { .awsOperation("BatchWriteItem") .execute(client -> client.batchWriteItem(writeItemRequest(0))) .stableOperation("BatchWriteItem") + .batchSize(0) .build(), BatchScenario.builder("writeItemSingle") .awsOperation("BatchWriteItem") diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java b/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java index 5e80b7b96062..f014ccc32521 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java @@ -52,7 +52,7 @@ public void onStart( Long batchSize = extractBatchSize(operation, executionAttributes); if (emitStableDatabaseSemconv()) { attributes.put(DB_OPERATION_NAME, getStableOperationName(operation, batchSize)); - if (isBatch(batchSize)) { + if (shouldEmitBatchSize(batchSize)) { attributes.put(DB_OPERATION_BATCH_SIZE, batchSize); } } @@ -134,8 +134,10 @@ private static long countBatchWriteItems(Map requestItems) { return count; } - private static boolean isBatch(@Nullable Long batchSize) { - return batchSize != null && batchSize > 1; + // db.operation.batch.size is captured for every batch request (including an empty batch with + // size 0); it is only omitted for a single-item batch, which is reported as a non-batch operation + private static boolean shouldEmitBatchSize(@Nullable Long batchSize) { + return batchSize != null && batchSize != 1; } @Nullable diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java index f9c92b6ba68e..b63efee620e6 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java @@ -566,13 +566,14 @@ void batchOperation(BatchScenario scenario) { @SuppressWarnings("deprecation") // uses deprecated semconv private static Stream batchScenarios() { return Stream.of( - // an empty batch still produces a span, but keeps the raw batch operation name and - // emits no db.operation.batch.size, db.collection.name or table-name attributes + // an empty batch still produces a span with the raw batch operation name and + // db.operation.batch.size 0, but no db.collection.name or table-name attributes BatchScenario.builder("getItemEmpty") .awsOperation("BatchGetItem") .responseContent("{\"ConsumedCapacity\":[]}") .execute(c -> c.batchGetItem(b -> b.requestItems(ImmutableMap.of()))) .stableOperation("BatchGetItem") + .batchSize(0) .build(), // a single-item batch is not a batch, so it uses the singular item operation and emits // no db.operation.batch.size @@ -631,6 +632,7 @@ private static Stream batchScenarios() { .responseContent("{\"ConsumedCapacity\":[]}") .execute(c -> c.batchWriteItem(b -> b.requestItems(ImmutableMap.of()))) .stableOperation("BatchWriteItem") + .batchSize(0) .build(), // a single-item batch is not a batch, so it uses the singular item operation and emits // no db.operation.batch.size diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index c46f9b474d6f..1857370b73e5 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -367,12 +367,13 @@ void batchStatement(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( - // an empty batch still produces a client span, but with no query text, summary, - // operation or batch size; the span name falls back to the database system name + // an empty batch still produces a client span carrying db.operation.batch.size 0, but with + // no query text, summary or operation; the span name falls back to the database system name BatchScenario.builder("empty") .buildBatch(session -> new BatchStatement()) .spanName("cassandra") .oldSpanName("DB Query") + .batchSize(0) .build(), // a single-statement batch is executed as a normal statement (not a batch): it has the // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index 05083e7b8688..cb4ff3554ea0 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -276,12 +276,13 @@ void batchStatement(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( - // an empty batch still produces a client span, but with no query text, summary, - // operation or batch size; the span name falls back to the database system name + // an empty batch still produces a client span carrying db.operation.batch.size 0, but with + // no query text, summary or operation; the span name falls back to the database system name BatchScenario.builder("empty") .buildBatch(session -> BatchStatement.newInstance(DefaultBatchType.LOGGED)) .spanName("cassandra") .oldSpanName("DB Query") + .batchSize(0) .build(), // a single-statement batch is executed as a normal statement (not a batch): it has the // normal INSERT span name in both modes, db.operation and db.cassandra.table, and no diff --git a/instrumentation/jdbc/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jdbc/JdbcAdviceScope.java b/instrumentation/jdbc/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jdbc/JdbcAdviceScope.java index f5a3344b3e2d..a610bb7ab749 100644 --- a/instrumentation/jdbc/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jdbc/JdbcAdviceScope.java +++ b/instrumentation/jdbc/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jdbc/JdbcAdviceScope.java @@ -77,6 +77,8 @@ private static JdbcAdviceScope start(CallDepth callDepth, Supplier re @Nullable private static DbRequest createBatchRequest(Statement statement) { + // reaching here means executeBatch()/executeLargeBatch() was called, so this is always a batch + // execution; an empty batch (no addBatch() calls) reports db.operation.batch.size 0 if (statement instanceof PreparedStatement) { String sql = JdbcData.PREPARED_STATEMENT.get((PreparedStatement) statement); if (sql == null) { @@ -84,11 +86,11 @@ private static DbRequest createBatchRequest(Statement statement) { } Long batchSize = JdbcData.getPreparedStatementBatchSize((PreparedStatement) statement); Map parameters = JdbcData.getParameters((PreparedStatement) statement); - return DbRequest.create(statement, sql, batchSize, parameters, true); + return DbRequest.create(statement, sql, batchSize != null ? batchSize : 0L, parameters, true); } else { JdbcData.StatementBatchInfo batchInfo = JdbcData.getStatementBatchInfo(statement); if (batchInfo == null) { - return DbRequest.create(statement, emptyList(), null, false); + return DbRequest.create(statement, emptyList(), 0L, false); } else { return DbRequest.create( statement, batchInfo.getQueryTexts(), batchInfo.getBatchSize(), false); diff --git a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java index be668c8384b1..81d08010901d 100644 --- a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java +++ b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java @@ -1886,12 +1886,13 @@ void testProxyPreparedStatement() throws SQLException { // is enough to lock down its shape static Stream batchCasesStream() { return Stream.of( - // an empty batch still produces a client span, but with no query text or batch size; - // the span name falls back to the database namespace in both modes + // an empty batch still produces a client span carrying db.operation.batch.size 0, but no + // query text; the span name falls back to the database namespace in both modes BatchScenario.builder() .name("empty") .spanName(DATABASE_NAME_LOWER) .oldSpanName(DATABASE_NAME_LOWER) + .batchSize(0) .build(), // a single-statement batch is not a batch (size 1), so it looks like a normal // statement and carries db.statement/db.operation/db.sql.table under old semconv diff --git a/instrumentation/r2dbc-1.0/library/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/internal/DbExecution.java b/instrumentation/r2dbc-1.0/library/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/internal/DbExecution.java index 96dd807309cd..4430928ffa8b 100644 --- a/instrumentation/r2dbc-1.0/library/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/internal/DbExecution.java +++ b/instrumentation/r2dbc-1.0/library/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/internal/DbExecution.java @@ -14,6 +14,7 @@ import static java.util.stream.Collectors.toList; import io.opentelemetry.context.Context; +import io.r2dbc.proxy.core.ExecutionType; import io.r2dbc.proxy.core.QueryExecutionInfo; import io.r2dbc.proxy.core.QueryInfo; import io.r2dbc.spi.Connection; @@ -109,8 +110,13 @@ public DbExecution(QueryExecutionInfo queryInfo, ConnectionFactoryOptions factor query -> R2dbcSqlCommenterUtil.getOriginalQuery(queryInfo.getConnectionInfo(), query)) .collect(toList()); + // db.operation.batch.size applies only to Batch executions (Connection#createBatch); a plain + // Statement always reports getBatchSize() == 0 and must not be treated as a batch. For a Batch + // the size is captured for every execution (including an empty batch with size 0) and only + // omitted for a single-statement batch (size 1) int queryInfoBatchSize = queryInfo.getBatchSize(); - this.batchSize = queryInfoBatchSize > 1 ? (long) queryInfoBatchSize : null; + boolean isBatch = queryInfo.getType() == ExecutionType.BATCH; + this.batchSize = isBatch && queryInfoBatchSize != 1 ? (long) queryInfoBatchSize : null; this.parameterizedQuery = queryInfo.getQueries().stream() .anyMatch(queryInfo1 -> !queryInfo1.getBindingsList().isEmpty()); diff --git a/instrumentation/r2dbc-1.0/library/src/test/java/io/opentelemetry/instrumentation/r2dbc/v1_0/DbExecutionTest.java b/instrumentation/r2dbc-1.0/library/src/test/java/io/opentelemetry/instrumentation/r2dbc/v1_0/DbExecutionTest.java index d986cc5997bb..054a1049b7b9 100644 --- a/instrumentation/r2dbc-1.0/library/src/test/java/io/opentelemetry/instrumentation/r2dbc/v1_0/DbExecutionTest.java +++ b/instrumentation/r2dbc-1.0/library/src/test/java/io/opentelemetry/instrumentation/r2dbc/v1_0/DbExecutionTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.when; import io.opentelemetry.instrumentation.r2dbc.v1_0.internal.DbExecution; +import io.r2dbc.proxy.core.ExecutionType; import io.r2dbc.proxy.core.QueryExecutionInfo; import io.r2dbc.proxy.core.QueryInfo; import io.r2dbc.proxy.test.MockConnectionInfo; @@ -58,6 +59,7 @@ void dbExecution() { void dbExecutionWithBatch() { QueryExecutionInfo queryExecutionInfo = MockQueryExecutionInfo.builder() + .type(ExecutionType.BATCH) .queryInfo(new QueryInfo("INSERT INTO person VALUES(1)")) .queryInfo(new QueryInfo("INSERT INTO person VALUES(2)")) .batchSize(2) @@ -73,10 +75,28 @@ void dbExecutionWithBatch() { assertThat(dbExecution.getBatchSize()).isEqualTo(2); } + @Test + void dbExecutionWithEmptyBatch() { + QueryExecutionInfo queryExecutionInfo = + MockQueryExecutionInfo.builder() + .type(ExecutionType.BATCH) + .batchSize(0) + .connectionInfo(MockConnectionInfo.builder().build()) + .build(); + ConnectionFactoryOptions factoryOptions = + ConnectionFactoryOptions.parse("r2dbc:postgresql://localhost/db"); + + DbExecution dbExecution = new DbExecution(queryExecutionInfo, factoryOptions); + + // an empty batch still reports db.operation.batch.size 0 + assertThat(dbExecution.getBatchSize()).isEqualTo(0); + } + @Test void dbExecutionWithBatchSizeOne() { QueryExecutionInfo queryExecutionInfo = MockQueryExecutionInfo.builder() + .type(ExecutionType.BATCH) .queryInfo(new QueryInfo("INSERT INTO person VALUES(1)")) .batchSize(1) .connectionInfo(MockConnectionInfo.builder().build()) @@ -87,6 +107,7 @@ void dbExecutionWithBatchSizeOne() { DbExecution dbExecution = new DbExecution(queryExecutionInfo, factoryOptions); assertThat(dbExecution.getRawQueryTexts()).containsExactly("INSERT INTO person VALUES(1)"); + // a single-statement batch is reported as a non-batch, so it has no db.operation.batch.size assertThat(dbExecution.getBatchSize()).isNull(); } diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index d79a628fbe39..ebfe45c51db7 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -360,10 +360,10 @@ void batchQueries(BatchScenario scenario) { String connectionString = MARIADB.system + "://localhost:" + port; if (scenario.queries.isEmpty()) { - // an empty batch fails to execute and produces a client span with no operation, summary or - // batch size; the span name falls back to the database namespace. under old semconv it still - // carries db.user/db.connection_string and an empty db.statement; under stable semconv it - // records the error instead (error.type is stable-only) + // an empty batch fails to execute and produces a client span with no operation or summary, + // but carries db.operation.batch.size 0; the span name falls back to the database namespace. + // under old semconv it still carries db.user/db.connection_string and an empty db.statement; + // under stable semconv it records the error instead (error.type is stable-only) assertThat(thrown).isInstanceOf(NoSuchElementException.class); getTesting() .waitAndAssertTraces( @@ -384,6 +384,9 @@ void batchQueries(BatchScenario scenario) { equalTo( maybeStable(DB_STATEMENT), emitStableDatabaseSemconv() ? null : ""), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? 0L : null), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, container.getHost()), equalTo(SERVER_PORT, port), diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/QueryExecutorInstrumentation.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/QueryExecutorInstrumentation.java index 5b75e7972880..57bcf0a144c5 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/QueryExecutorInstrumentation.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/QueryExecutorInstrumentation.java @@ -101,7 +101,9 @@ public static AdviceScope start(Object queryExecutor, String methodName, Object[ } if (methodName.equals("executeBatchQuery") && argument instanceof Collection) { int size = ((Collection) argument).size(); - batchSize = size > 1 ? (long) size : null; + // capture the batch size for every batch execution (including an empty batch with size + // 0); it is only omitted for a single-statement batch (size 1) + batchSize = size != 1 ? (long) size : null; } } if (sql == null || promiseInternal == null) { diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java index f2eede47b02d..695290b52438 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java @@ -365,12 +365,13 @@ private static void recreateBatchTestTable() throws Exception { private static Stream batchScenarios() { return Stream.of( // an empty batch is rejected before sending, so it looks like a single statement but - // records the error and emits no db.operation.batch.size + // records the error and carries db.operation.batch.size 0 BatchScenario.builder("empty") .tuples(emptyList()) .stableSpanName("insert batch_test") .stableSummary("insert batch_test") .errorType("io.vertx.core.impl.NoStackTraceThrowable") + .batchSize(0) .build(), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/QueryExecutorInstrumentation.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/QueryExecutorInstrumentation.java index 62983148f3fa..2e6497f945e4 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/QueryExecutorInstrumentation.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/QueryExecutorInstrumentation.java @@ -100,7 +100,9 @@ public static AdviceScope start(Object queryExecutor, String methodName, Object[ } if (methodName.equals("executeBatchQuery") && argument instanceof Collection) { int size = ((Collection) argument).size(); - batchSize = size > 1 ? (long) size : null; + // capture the batch size for every batch execution (including an empty batch with size + // 0); it is only omitted for a single-statement batch (size 1) + batchSize = size != 1 ? (long) size : null; } } if (sql == null || promiseInternal == null) { diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java index 53eca7149e7e..9f9dd710f7ee 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java @@ -366,12 +366,13 @@ private static void recreateBatchTestTable() throws Exception { private static Stream batchScenarios() { return Stream.of( // an empty batch is rejected before sending, so it looks like a single statement but - // records the error and emits no db.operation.batch.size + // records the error and carries db.operation.batch.size 0 BatchScenario.builder("empty") .tuples(emptyList()) .stableSpanName("insert batch_test") .stableSummary("insert batch_test") .errorType("io.vertx.core.VertxException") + .batchSize(0) .build(), // a single-statement batch is not a batch (size 1), so it emits no // db.operation.batch.size and no BATCH prefix From 92766d65b187f560457b2ffe18a934bc18ebf8e8 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 21:28:29 -0700 Subject: [PATCH 21/31] Improve DynamoDB BatchWriteItem operation names For stable semconv, inspect BatchWriteItem requests to determine the actual operation type: - Single PutRequest -> PutItem - Single DeleteRequest -> DeleteItem - Multiple PutRequests -> BATCH PutItem - Multiple DeleteRequests -> BATCH DeleteItem - Mixed Put/Delete -> BATCH This aligns DynamoDB with SQL/Cassandra batch naming conventions where specific operation names are used when all items in a batch perform the same operation type. Also adds test coverage for all BatchWriteItem scenarios. --- .../internal/DynamoDbAttributesExtractor.java | 72 ++++++++++++++- .../awssdk/v1_11/internal/RequestAccess.java | 36 ++++++++ .../v1_11/AbstractDynamoDbClientTest.java | 63 +++++++++++--- .../internal/DynamoDbAttributesExtractor.java | 79 ++++++++++++++++- .../v2_2/AbstractAws2ClientCoreTest.java | 87 +++++++++++++++++-- 5 files changed, 309 insertions(+), 28 deletions(-) diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java index ce9fb4a63904..2443dec53d5d 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java @@ -38,6 +38,12 @@ class DynamoDbAttributesExtractor implements AttributesExtractor, Res // copied from DbIncubatingAttributes.DbSystemNameIncubatingValues private static final String AWS_DYNAMODB = "aws.dynamodb"; + // write operation type classification + private static final int WRITE_OP_NONE = 0; + private static final int WRITE_OP_PUT = 1; + private static final int WRITE_OP_DELETE = 2; + private static final int WRITE_OP_MIXED = 3; + @Override public void onStart(AttributesBuilder attributes, Context parentContext, Request request) { if (emitStableDatabaseSemconv()) { @@ -49,8 +55,12 @@ public void onStart(AttributesBuilder attributes, Context parentContext, Request String operation = getOperationName(request.getOriginalRequest()); Long batchSize = extractBatchSize(operation, request.getOriginalRequest()); + int writeOpType = + "BatchWriteItem".equals(operation) + ? extractWriteOperationType(request.getOriginalRequest()) + : WRITE_OP_NONE; if (emitStableDatabaseSemconv()) { - attributes.put(DB_OPERATION_NAME, getStableOperationName(operation, batchSize)); + attributes.put(DB_OPERATION_NAME, getStableOperationName(operation, batchSize, writeOpType)); if (shouldEmitBatchSize(batchSize)) { attributes.put(DB_OPERATION_BATCH_SIZE, batchSize); } @@ -96,16 +106,31 @@ private static String getSingleCollectionName(Map requestItems) { @Nullable private static String getStableOperationName( - @Nullable String operation, @Nullable Long batchSize) { + @Nullable String operation, @Nullable Long batchSize, int writeOpType) { if ("BatchGetItem".equals(operation)) { return getStableBatchOperationName(batchSize, "GetItem", operation); } if ("BatchWriteItem".equals(operation)) { - return getStableBatchOperationName(batchSize, "WriteItem", operation); + return getStableWriteOperationName(batchSize, writeOpType); } return operation; } + private static String getStableWriteOperationName(@Nullable Long batchSize, int writeOpType) { + if (batchSize == null || batchSize == 0) { + return "BatchWriteItem"; + } + String itemOp = writeOpType == WRITE_OP_PUT ? "PutItem" : "DeleteItem"; + if (batchSize == 1) { + return itemOp; + } + // mixed operations collapse to bare BATCH (consistent with SQL/Cassandra) + if (writeOpType == WRITE_OP_MIXED) { + return "BATCH"; + } + return "BATCH " + itemOp; + } + private static String getStableBatchOperationName( @Nullable Long batchSize, String itemOperation, String batchOperation) { if (batchSize == null || batchSize == 0) { @@ -157,6 +182,47 @@ private static long countBatchWriteItems(Map requestItems) { return count; } + /** + * Extracts the write operation type from a BatchWriteItem request. Returns WRITE_OP_PUT if all + * requests are PutRequests, WRITE_OP_DELETE if all are DeleteRequests, WRITE_OP_MIXED if both + * types are present, or WRITE_OP_NONE if the request is empty or cannot be inspected. + */ + private static int extractWriteOperationType(Object request) { + Map requestItems = RequestAccess.getRequestItems(request); + if (requestItems == null) { + return WRITE_OP_NONE; + } + + int result = WRITE_OP_NONE; + for (Object writeRequests : requestItems.values()) { + if (writeRequests instanceof Collection) { + for (Object writeRequest : (Collection) writeRequests) { + int opType = classifyWriteRequest(writeRequest); + if (opType == WRITE_OP_NONE) { + continue; + } + if (result == WRITE_OP_NONE) { + result = opType; + } else if (result != opType) { + return WRITE_OP_MIXED; + } + } + } + } + return result; + } + + private static int classifyWriteRequest(Object writeRequest) { + // WriteRequest has getPutRequest() and getDeleteRequest() methods; exactly one returns non-null + if (RequestAccess.hasPutRequest(writeRequest)) { + return WRITE_OP_PUT; + } + if (RequestAccess.hasDeleteRequest(writeRequest)) { + return WRITE_OP_DELETE; + } + return WRITE_OP_NONE; + } + // db.operation.batch.size is captured for every batch request (including an empty batch with // size 0); it is only omitted for a single-item batch, which is reported as a non-batch operation private static boolean shouldEmitBatchSize(@Nullable Long batchSize) { diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/RequestAccess.java b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/RequestAccess.java index f4fe2066f243..a794d04d9ba5 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/RequestAccess.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/RequestAccess.java @@ -111,6 +111,24 @@ static List getKeys(Object request) { return invokeOrNull(access.getKeys, request, List.class); } + /** + * Returns true if the given WriteRequest contains a PutRequest, false otherwise. Uses reflection + * to call getPutRequest() on the WriteRequest object. + */ + static boolean hasPutRequest(Object writeRequest) { + WriteRequestAccess access = WriteRequestAccess.ACCESSORS.get(writeRequest.getClass()); + return invokeOrNull(access.getPutRequest, writeRequest, Object.class) != null; + } + + /** + * Returns true if the given WriteRequest contains a DeleteRequest, false otherwise. Uses + * reflection to call getDeleteRequest() on the WriteRequest object. + */ + static boolean hasDeleteRequest(Object writeRequest) { + WriteRequestAccess access = WriteRequestAccess.ACCESSORS.get(writeRequest.getClass()); + return invokeOrNull(access.getDeleteRequest, writeRequest, Object.class) != null; + } + @Nullable static String getSnsTopicArn(Object request) { RequestAccess access = REQUEST_ACCESSORS.get(request.getClass()); @@ -220,4 +238,22 @@ private static MethodHandle findGetLambdaArnMethod() { } } } + + private static class WriteRequestAccess { + private static final ClassValue ACCESSORS = + new ClassValue() { + @Override + protected WriteRequestAccess computeValue(Class type) { + return new WriteRequestAccess(type); + } + }; + + @Nullable private final MethodHandle getPutRequest; + @Nullable private final MethodHandle getDeleteRequest; + + private WriteRequestAccess(Class clz) { + getPutRequest = findAccessorOrNull(clz, "getPutRequest", Object.class); + getDeleteRequest = findAccessorOrNull(clz, "getDeleteRequest", Object.class); + } + } } diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java index 84d0f12cc6fa..1bd052fcdb56 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java @@ -151,27 +151,42 @@ private static Stream batchScenarios() { .stableOperation("BatchWriteItem") .batchSize(0) .build(), - BatchScenario.builder("writeItemSingle") + // a single put request is reported as PutItem + BatchScenario.builder("writeItemSinglePut") .awsOperation("BatchWriteItem") - .execute(client -> client.batchWriteItem(writeItemRequest(1))) - .stableOperation("WriteItem") + .execute(client -> client.batchWriteItem(putItemsRequest(1))) + .stableOperation("PutItem") .hasCollection() .build(), - BatchScenario.builder("writeItemTwo") + // a single delete request is reported as DeleteItem + BatchScenario.builder("writeItemSingleDelete") .awsOperation("BatchWriteItem") - .execute(client -> client.batchWriteItem(writeItemRequest(2))) - .stableOperation("BATCH WriteItem") + .execute(client -> client.batchWriteItem(deleteItemsRequest(1))) + .stableOperation("DeleteItem") + .hasCollection() + .build(), + // two put requests are reported as BATCH PutItem + BatchScenario.builder("writeItemTwoPuts") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(putItemsRequest(2))) + .stableOperation("BATCH PutItem") .batchSize(2) .hasCollection() .build(), - // a batch mixing a put and a delete in one table: unlike the SQL/Cassandra matrices where - // a batch with differing operations collapses to just "BATCH", DynamoDB derives the - // operation name from the item count alone, so a put+delete write batch still reports the - // shared "BATCH WriteItem" operation (and the single collection) + // two delete requests are reported as BATCH DeleteItem + BatchScenario.builder("writeItemTwoDeletes") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(deleteItemsRequest(2))) + .stableOperation("BATCH DeleteItem") + .batchSize(2) + .hasCollection() + .build(), + // a batch mixing a put and a delete collapses to bare "BATCH" + // (consistent with SQL/Cassandra mixed-operation batches) BatchScenario.builder("writeItemMixed") .awsOperation("BatchWriteItem") .execute(client -> client.batchWriteItem(mixedWriteItemRequest())) - .stableOperation("BATCH WriteItem") + .stableOperation("BATCH") .batchSize(2) .hasCollection() .build()); @@ -195,17 +210,39 @@ private static BatchWriteItemRequest writeItemRequest(int count) { } List writes = new ArrayList<>(); for (int i = 0; i < count; i++) { - writes.add(writeRequest("value" + i)); + writes.add(putRequest("value" + i)); + } + return new BatchWriteItemRequest().withRequestItems(singletonMap("sometable", writes)); + } + + private static BatchWriteItemRequest putItemsRequest(int count) { + List writes = new ArrayList<>(); + for (int i = 0; i < count; i++) { + writes.add(putRequest("value" + i)); + } + return new BatchWriteItemRequest().withRequestItems(singletonMap("sometable", writes)); + } + + private static BatchWriteItemRequest deleteItemsRequest(int count) { + List writes = new ArrayList<>(); + for (int i = 0; i < count; i++) { + writes.add(deleteRequest("value" + i)); } return new BatchWriteItemRequest().withRequestItems(singletonMap("sometable", writes)); } - private static WriteRequest writeRequest(String value) { + private static WriteRequest putRequest(String value) { return new WriteRequest() .withPutRequest( new PutRequest().withItem(singletonMap("key", new AttributeValue().withS(value)))); } + private static WriteRequest deleteRequest(String value) { + return new WriteRequest() + .withDeleteRequest( + new DeleteRequest().withKey(singletonMap("key", new AttributeValue().withS(value)))); + } + private static BatchWriteItemRequest mixedWriteItemRequest() { List writes = asList( diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java b/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java index f014ccc32521..a8980e18b641 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java @@ -35,6 +35,12 @@ class DynamoDbAttributesExtractor implements AttributesExtractor requestItems) { return count; } + /** + * Extracts the write operation type from a BatchWriteItem request. Returns WRITE_OP_PUT if all + * requests are PutRequests, WRITE_OP_DELETE if all are DeleteRequests, WRITE_OP_MIXED if both + * types are present, or WRITE_OP_NONE if the request is empty or cannot be inspected. + */ + private int extractWriteOperationType(ExecutionAttributes executionAttributes) { + SdkRequest request = + executionAttributes.getAttribute(TracingExecutionInterceptor.SDK_REQUEST_ATTRIBUTE); + if (request == null) { + return WRITE_OP_NONE; + } + Optional requestItems = request.getValueForField("RequestItems", Object.class); + if (!requestItems.isPresent() || !(requestItems.get() instanceof Map)) { + return WRITE_OP_NONE; + } + + int result = WRITE_OP_NONE; + for (Object writeRequests : ((Map) requestItems.get()).values()) { + if (writeRequests instanceof Collection) { + for (Object writeRequest : (Collection) writeRequests) { + int opType = classifyWriteRequest(writeRequest); + if (opType == WRITE_OP_NONE) { + continue; + } + if (result == WRITE_OP_NONE) { + result = opType; + } else if (result != opType) { + return WRITE_OP_MIXED; + } + } + } + } + return result; + } + + private int classifyWriteRequest(Object writeRequest) { + // WriteRequest has putRequest() and deleteRequest() methods; exactly one returns non-null + Object putRequest = next(writeRequest, "PutRequest"); + if (putRequest != null) { + return WRITE_OP_PUT; + } + Object deleteRequest = next(writeRequest, "DeleteRequest"); + if (deleteRequest != null) { + return WRITE_OP_DELETE; + } + return WRITE_OP_NONE; + } + // db.operation.batch.size is captured for every batch request (including an empty batch with // size 0); it is only omitted for a single-item batch, which is reported as a non-batch operation private static boolean shouldEmitBatchSize(@Nullable Long batchSize) { diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java index b63efee620e6..c2ec4fa07349 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java @@ -636,7 +636,7 @@ private static Stream batchScenarios() { .build(), // a single-item batch is not a batch, so it uses the singular item operation and emits // no db.operation.batch.size - BatchScenario.builder("writeItemSingle") + BatchScenario.builder("writeItemSinglePut") .awsOperation("BatchWriteItem") .responseContent(getResponseContent("BatchWriteItem")) .execute( @@ -658,13 +658,43 @@ private static Stream batchScenarios() { .build())) .build()) .build()))))) - .stableOperation("WriteItem") + .stableOperation("PutItem") .hasCollection() .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") .assertMetric() .build(), - BatchScenario.builder("writeItemTwo") + // a single delete request is reported as DeleteItem + BatchScenario.builder("writeItemSingleDelete") + .awsOperation("BatchWriteItem") + .responseContent(getResponseContent("BatchWriteItem")) + .execute( + c -> + c.batchWriteItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + singletonList( + WriteRequest.builder() + .deleteRequest( + DeleteRequest.builder() + .key( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build())) + .build()) + .build()))))) + .stableOperation("DeleteItem") + .hasCollection() + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() + .build(), + // two put requests are reported as BATCH PutItem + BatchScenario.builder("writeItemTwoPuts") .awsOperation("BatchWriteItem") .responseContent(getResponseContent("BatchWriteItem")) .execute( @@ -697,17 +727,56 @@ private static Stream batchScenarios() { .build())) .build()) .build()))))) - .stableOperation("BATCH WriteItem") + .stableOperation("BATCH PutItem") + .hasCollection() + .batchSize(2) + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() + .build(), + // two delete requests are reported as BATCH DeleteItem + BatchScenario.builder("writeItemTwoDeletes") + .awsOperation("BatchWriteItem") + .responseContent(getResponseContent("BatchWriteItem")) + .execute( + c -> + c.batchWriteItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", + asList( + WriteRequest.builder() + .deleteRequest( + DeleteRequest.builder() + .key( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build())) + .build()) + .build(), + WriteRequest.builder() + .deleteRequest( + DeleteRequest.builder() + .key( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("anotherValue") + .build())) + .build()) + .build()))))) + .stableOperation("BATCH DeleteItem") .hasCollection() .batchSize(2) .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") .assertMetric() .build(), - // a batch mixing a put and a delete in one table: unlike the SQL/Cassandra matrices where - // a batch with differing operations collapses to just "BATCH", DynamoDB derives the - // operation name from the item count alone, so a put+delete write batch still reports the - // shared "BATCH WriteItem" operation (and the single collection) + // a batch mixing a put and a delete in one table collapses to bare "BATCH" + // (consistent with SQL/Cassandra mixed-operation batches) BatchScenario.builder("writeItemMixed") .awsOperation("BatchWriteItem") .responseContent(getResponseContent("BatchWriteItem")) @@ -741,7 +810,7 @@ private static Stream batchScenarios() { .build())) .build()) .build()))))) - .stableOperation("BATCH WriteItem") + .stableOperation("BATCH") .hasCollection() .batchSize(2) .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") From 3b89f476d1cfbc33a7121b9122486bd451811484 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 16 Jun 2026 22:28:31 -0700 Subject: [PATCH 22/31] Fix stable DB semconv batch assertions R2DBC batch getter tests built a mock query execution with a batch size but without marking it as an R2DBC batch, so stable semconv batch attributes were not available. AWS SDK 1.11 looked up DynamoDB WriteRequest accessors with an exact Object return type, so PutRequest/DeleteRequest methods were not found and batch write operations were misclassified. Mark the R2DBC mock as a batch and resolve AWS v1.11 write accessors without requiring a specific return type. Validation: ./gradlew :instrumentation:r2dbc-1.0:library:testStableSemconv --tests "io.opentelemetry.instrumentation.r2dbc.v1_0.R2dbcSqlAttributesGetterTest" --rerun --no-daemon ./gradlew :instrumentation:aws-sdk:aws-sdk-1.11:library:testStableSemconv --tests "*batchOperation*" --rerun --no-daemon ./gradlew :instrumentation:aws-sdk:aws-sdk-1.11:javaagent:testStableSemconv --tests "*batchOperation*" --rerun --no-daemon ./gradlew :instrumentation:r2dbc-1.0:library:spotlessApply :instrumentation:aws-sdk:aws-sdk-1.11:library:spotlessApply --no-daemon --- .../awssdk/v1_11/internal/RequestAccess.java | 44 ++++++++++++++++--- .../v1_0/R2dbcSqlAttributesGetterTest.java | 2 + 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/RequestAccess.java b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/RequestAccess.java index a794d04d9ba5..54d5405e3b37 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/RequestAccess.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/RequestAccess.java @@ -8,6 +8,7 @@ import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; import javax.annotation.Nullable; @@ -117,7 +118,7 @@ static List getKeys(Object request) { */ static boolean hasPutRequest(Object writeRequest) { WriteRequestAccess access = WriteRequestAccess.ACCESSORS.get(writeRequest.getClass()); - return invokeOrNull(access.getPutRequest, writeRequest, Object.class) != null; + return access.invokeGetPutRequest(writeRequest) != null; } /** @@ -126,7 +127,7 @@ static boolean hasPutRequest(Object writeRequest) { */ static boolean hasDeleteRequest(Object writeRequest) { WriteRequestAccess access = WriteRequestAccess.ACCESSORS.get(writeRequest.getClass()); - return invokeOrNull(access.getDeleteRequest, writeRequest, Object.class) != null; + return access.invokeGetDeleteRequest(writeRequest) != null; } @Nullable @@ -248,12 +249,43 @@ protected WriteRequestAccess computeValue(Class type) { } }; - @Nullable private final MethodHandle getPutRequest; - @Nullable private final MethodHandle getDeleteRequest; + @Nullable private final Method getPutRequest; + @Nullable private final Method getDeleteRequest; private WriteRequestAccess(Class clz) { - getPutRequest = findAccessorOrNull(clz, "getPutRequest", Object.class); - getDeleteRequest = findAccessorOrNull(clz, "getDeleteRequest", Object.class); + getPutRequest = findMethodOrNull(clz, "getPutRequest"); + getDeleteRequest = findMethodOrNull(clz, "getDeleteRequest"); + } + + @Nullable + Object invokeGetPutRequest(Object obj) { + return invokeMethod(getPutRequest, obj); + } + + @Nullable + Object invokeGetDeleteRequest(Object obj) { + return invokeMethod(getDeleteRequest, obj); + } + + @Nullable + private static Object invokeMethod(@Nullable Method method, Object obj) { + if (method == null) { + return null; + } + try { + return method.invoke(obj); + } catch (Throwable ignored) { + return null; + } + } + + @Nullable + private static Method findMethodOrNull(Class clz, String methodName) { + try { + return clz.getMethod(methodName); + } catch (Throwable ignored) { + return null; + } } } } diff --git a/instrumentation/r2dbc-1.0/library/src/test/java/io/opentelemetry/instrumentation/r2dbc/v1_0/R2dbcSqlAttributesGetterTest.java b/instrumentation/r2dbc-1.0/library/src/test/java/io/opentelemetry/instrumentation/r2dbc/v1_0/R2dbcSqlAttributesGetterTest.java index 07665eea080d..6a2aa2d01c0a 100644 --- a/instrumentation/r2dbc-1.0/library/src/test/java/io/opentelemetry/instrumentation/r2dbc/v1_0/R2dbcSqlAttributesGetterTest.java +++ b/instrumentation/r2dbc-1.0/library/src/test/java/io/opentelemetry/instrumentation/r2dbc/v1_0/R2dbcSqlAttributesGetterTest.java @@ -10,6 +10,7 @@ import io.opentelemetry.instrumentation.r2dbc.v1_0.internal.DbExecution; import io.opentelemetry.instrumentation.r2dbc.v1_0.internal.R2dbcSqlAttributesGetter; +import io.r2dbc.proxy.core.ExecutionType; import io.r2dbc.proxy.core.QueryExecutionInfo; import io.r2dbc.proxy.core.QueryInfo; import io.r2dbc.proxy.test.MockConnectionInfo; @@ -43,6 +44,7 @@ void rawQueryTextsForSingleQuery() { void rawQueryTextsForBatch() { QueryExecutionInfo queryExecutionInfo = MockQueryExecutionInfo.builder() + .type(ExecutionType.BATCH) .queryInfo(new QueryInfo("INSERT INTO person VALUES(1)")) .queryInfo(new QueryInfo("INSERT INTO person VALUES(2)")) .batchSize(2) From 1513f55dc77195f946a8b500485a6b90eb2811ad Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Wed, 17 Jun 2026 12:54:13 -0700 Subject: [PATCH 23/31] Aggregate Redis batch spans --- .../v1_4/JedisConnectionInstrumentation.java | 67 +++++++ .../jedis/v1_4/JedisDbAttributesGetter.java | 17 +- .../v1_4/JedisInstrumentationModule.java | 5 +- .../v1_4/JedisPipelineInstrumentation.java | 44 +++++ .../jedis/v1_4/JedisRequest.java | 64 +++++- .../jedis/AbstractJedisTest.java | 125 ++++++++++++ .../v3_0/JedisConnectionInstrumentation.java | 4 + .../jedis/v3_0/JedisDbAttributesGetter.java | 6 + .../v3_0/JedisInstrumentationModule.java | 5 +- .../v3_0/JedisPipelineInstrumentation.java | 114 +++++++++++ .../jedis/v3_0/JedisRequest.java | 67 ++++++- .../jedis/v3_0/Jedis30ClientTest.java | 123 ++++++++++++ .../v4_0/JedisConnectionInstrumentation.java | 18 +- .../jedis/v4_0/JedisDbAttributesGetter.java | 6 + .../v4_0/JedisInstrumentationModule.java | 5 +- .../v4_0/JedisPipelineInstrumentation.java | 108 +++++++++++ .../jedis/v4_0/JedisRequest.java | 69 ++++++- .../jedis/v4_0/Jedis40ClientTest.java | 123 ++++++++++++ .../common/v1_4/JedisPipelineContext.java | 50 +++++ .../LettuceAsyncCommandsInstrumentation.java | 89 ++++++++- .../v4_0/LettuceBatchAttributesGetter.java | 42 ++++ .../lettuce/v4_0/LettuceBatchContext.java | 148 ++++++++++++++ .../lettuce/v4_0/LettuceBatchRequest.java | 47 +++++ .../lettuce/v4_0/LettuceSingletons.java | 16 ++ .../lettuce/v4_0/LettuceAsyncClientTest.java | 99 ++++++++++ .../LettuceAsyncCommandsInstrumentation.java | 91 ++++++++- .../v5_0/LettuceBatchAttributesGetter.java | 42 ++++ .../lettuce/v5_0/LettuceBatchContext.java | 148 ++++++++++++++ .../lettuce/v5_0/LettuceBatchRequest.java | 94 +++++++++ .../lettuce/v5_0/LettuceSingletons.java | 16 ++ .../lettuce/v5_0/LettuceAsyncClientTest.java | 115 +++++++++++ .../LettuceAsyncCommandsInstrumentation.java | 80 ++++++++ .../LettuceCommandHandlerInstrumentation.java | 47 +++++ .../v5_1/LettuceInstrumentationModule.java | 6 +- .../lettuce/v5_1/LettuceAsyncClientTest.java | 5 + .../v5_1/LettuceDbAttributesGetter.java | 6 + .../lettuce/v5_1/LettuceRequest.java | 48 +++++ .../lettuce/v5_1/LettuceTelemetry.java | 33 ++++ .../lettuce/v5_1/OpenTelemetryTracing.java | 182 ++++++++++++++++++ .../v5_1/AbstractLettuceAsyncClientTest.java | 156 +++++++++++++++ .../v3_0/RedisConnectionInstrumentation.java | 44 ++++- .../v3_17/RedisConnectionInstrumentation.java | 44 ++++- .../common/v3_0/RedissonBatchSpanManager.java | 81 ++++++++ .../redisson/common/v3_0/RedissonRequest.java | 24 +++ .../redisson/AbstractRedissonClientTest.java | 28 +-- 45 files changed, 2670 insertions(+), 81 deletions(-) create mode 100644 instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java create mode 100644 instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisPipelineInstrumentation.java create mode 100644 instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisPipelineInstrumentation.java create mode 100644 instrumentation/jedis/jedis-common-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/common/v1_4/JedisPipelineContext.java create mode 100644 instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchAttributesGetter.java create mode 100644 instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchContext.java create mode 100644 instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchRequest.java create mode 100644 instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchAttributesGetter.java create mode 100644 instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchContext.java create mode 100644 instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchRequest.java create mode 100644 instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java create mode 100644 instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceCommandHandlerInstrumentation.java create mode 100644 instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonBatchSpanManager.java diff --git a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisConnectionInstrumentation.java b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisConnectionInstrumentation.java index 1447f69baa64..3391621db11a 100644 --- a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisConnectionInstrumentation.java +++ b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisConnectionInstrumentation.java @@ -18,7 +18,10 @@ import io.opentelemetry.context.Scope; import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisPipelineContext; import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisRequestContext; +import java.util.ArrayList; +import java.util.List; import javax.annotation.Nullable; import net.bytebuddy.asm.Advice; import net.bytebuddy.description.type.TypeDescription; @@ -56,6 +59,8 @@ public void transform(TypeTransformer transformer) { "redis.clients.jedis.ProtocolCommand"))) .and(takesArgument(1, is(byte[][].class))), getClass().getName() + "$SendCommandWithArgsAdvice"); + transformer.applyAdviceToMethod( + named("getAll").and(takesArguments(0)), getClass().getName() + "$GetAllAdvice"); } public static class AdviceScope { @@ -72,6 +77,9 @@ private AdviceScope(Context context, Scope scope, JedisRequest request) { @Nullable public static AdviceScope start(JedisRequest request) { Context parentContext = currentContext(); + if (JedisPipelineContext.capture(request)) { + return null; + } if (!instrumenter().shouldStart(parentContext, request)) { return null; } @@ -85,6 +93,65 @@ public void end(@Nullable Throwable throwable) { } } + @SuppressWarnings("unused") + public static class GetAllAdvice { + + public static class PipelineAdviceScope { + private final Context context; + private final Scope scope; + private final JedisRequest request; + + private PipelineAdviceScope(Context context, Scope scope, JedisRequest request) { + this.context = context; + this.scope = scope; + this.request = request; + } + + @Nullable + public static PipelineAdviceScope start() { + Object pipeline = JedisPipelineContext.currentPipeline(); + if (pipeline == null) { + return null; + } + List capturedRequests = JedisPipelineContext.drain(pipeline); + if (capturedRequests.isEmpty()) { + return null; + } + List requests = new ArrayList<>(capturedRequests.size()); + for (Object capturedRequest : capturedRequests) { + requests.add((JedisRequest) capturedRequest); + } + JedisRequest request = JedisRequest.createPipeline(requests); + Context parentContext = currentContext(); + if (!instrumenter().shouldStart(parentContext, request)) { + return null; + } + Context context = instrumenter().start(parentContext, request); + return new PipelineAdviceScope(context, context.makeCurrent(), request); + } + + public void end(@Nullable Throwable throwable) { + scope.close(); + instrumenter().end(context, request, null, throwable); + } + } + + @Nullable + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static PipelineAdviceScope onEnter() { + return PipelineAdviceScope.start(); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void stopSpan( + @Advice.Thrown @Nullable Throwable throwable, + @Advice.Enter @Nullable PipelineAdviceScope adviceScope) { + if (adviceScope != null) { + adviceScope.end(throwable); + } + } + } + @SuppressWarnings("unused") public static class SendCommandNoArgsAdvice { diff --git a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisDbAttributesGetter.java b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisDbAttributesGetter.java index 58f182b48fb4..20a60ff5a616 100644 --- a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisDbAttributesGetter.java +++ b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisDbAttributesGetter.java @@ -5,19 +5,12 @@ package io.opentelemetry.javaagent.instrumentation.jedis.v1_4; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.instrumentation.api.incubator.config.internal.DbConfig; import io.opentelemetry.instrumentation.api.incubator.semconv.db.DbClientAttributesGetter; -import io.opentelemetry.instrumentation.api.incubator.semconv.db.RedisCommandSanitizer; import io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues; import javax.annotation.Nullable; final class JedisDbAttributesGetter implements DbClientAttributesGetter { - private static final RedisCommandSanitizer sanitizer = - RedisCommandSanitizer.create( - DbConfig.isQuerySanitizationEnabled(GlobalOpenTelemetry.get(), "jedis")); - @Override public String getDbSystemName(JedisRequest request) { return DbSystemNameIncubatingValues.REDIS; @@ -31,12 +24,18 @@ public String getDbNamespace(JedisRequest request) { @Override public String getDbQueryText(JedisRequest request) { - return sanitizer.sanitize(request.getCommand().name(), request.getArgs()); + return request.getQueryText(); } @Override public String getDbOperationName(JedisRequest request) { - return request.getCommand().name(); + return request.getOperationName(); + } + + @Override + @Nullable + public Long getDbOperationBatchSize(JedisRequest request) { + return request.getBatchSize(); } @Override diff --git a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisInstrumentationModule.java b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisInstrumentationModule.java index bd0cef65a0af..c505415b8294 100644 --- a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisInstrumentationModule.java +++ b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisInstrumentationModule.java @@ -30,6 +30,9 @@ public ElementMatcher.Junction classLoaderMatcher() { @Override public List typeInstrumentations() { - return asList(new JedisConnectionInstrumentation(), new JedisInstrumentation()); + return asList( + new JedisConnectionInstrumentation(), + new JedisInstrumentation(), + new JedisPipelineInstrumentation()); } } diff --git a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java new file mode 100644 index 000000000000..25fdfc6ae665 --- /dev/null +++ b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java @@ -0,0 +1,44 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.jedis.v1_4; + +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisPipelineContext; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +class JedisPipelineInstrumentation implements TypeInstrumentation { + @Override + public ElementMatcher typeMatcher() { + return named("redis.clients.jedis.Jedis"); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + named("pipelined").and(takesArgument(0, named("redis.clients.jedis.JedisPipeline"))), + getClass().getName() + "$PipelinedAdvice"); + } + + @SuppressWarnings("unused") + public static class PipelinedAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static void onEnter(@Advice.Argument(0) Object pipeline) { + JedisPipelineContext.enter(pipeline); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void onExit() { + JedisPipelineContext.exit(); + } + } +} diff --git a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisRequest.java b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisRequest.java index f40d3301ed82..1d7cbae2a3f4 100644 --- a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisRequest.java +++ b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisRequest.java @@ -8,25 +8,85 @@ import static java.util.Collections.emptyList; import com.google.auto.value.AutoValue; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.instrumentation.api.incubator.config.internal.DbConfig; +import io.opentelemetry.instrumentation.api.incubator.semconv.db.RedisCommandSanitizer; import java.util.List; +import java.util.StringJoiner; +import javax.annotation.Nullable; import redis.clients.jedis.Connection; import redis.clients.jedis.Protocol; @AutoValue public abstract class JedisRequest { + private static final RedisCommandSanitizer sanitizer = + RedisCommandSanitizer.create( + DbConfig.isQuerySanitizationEnabled(GlobalOpenTelemetry.get(), "jedis")); public static JedisRequest create(Connection connection, Protocol.Command command) { - return new AutoValue_JedisRequest(connection, command, emptyList()); + return new AutoValue_JedisRequest(connection, command, emptyList(), null, null, null); } public static JedisRequest create( Connection connection, Protocol.Command command, List args) { - return new AutoValue_JedisRequest(connection, command, args); + return new AutoValue_JedisRequest(connection, command, args, null, null, null); + } + + public static JedisRequest createPipeline(List requests) { + JedisRequest first = requests.get(0); + return new AutoValue_JedisRequest( + first.getConnection(), + null, + emptyList(), + pipelineOperationName(requests), + pipelineQueryText(requests), + requests.size() > 1 ? (long) requests.size() : null); } public abstract Connection getConnection(); + @Nullable public abstract Protocol.Command getCommand(); public abstract List getArgs(); + + @Nullable + abstract String getOperationNameOverride(); + + @Nullable + abstract String getQueryTextOverride(); + + @Nullable + public abstract Long getBatchSize(); + + public String getOperationName() { + String operationName = getOperationNameOverride(); + return operationName != null ? operationName : getCommand().name(); + } + + public String getQueryText() { + String queryText = getQueryTextOverride(); + return queryText != null ? queryText : sanitizer.sanitize(getOperationName(), getArgs()); + } + + private static String pipelineOperationName(List requests) { + if (requests.size() == 1) { + return requests.get(0).getOperationName(); + } + String commonOperationName = requests.get(0).getOperationName(); + for (int i = 1; i < requests.size(); i++) { + if (!commonOperationName.equals(requests.get(i).getOperationName())) { + return "PIPELINE"; + } + } + return "PIPELINE " + commonOperationName; + } + + private static String pipelineQueryText(List requests) { + StringJoiner joiner = new StringJoiner(";"); + for (JedisRequest request : requests) { + joiner.add(request.getQueryText()); + } + return joiner.toString(); + } } diff --git a/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/AbstractJedisTest.java b/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/AbstractJedisTest.java index d9b0811b15aa..397104b46d61 100644 --- a/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/AbstractJedisTest.java +++ b/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/AbstractJedisTest.java @@ -10,6 +10,7 @@ import static io.opentelemetry.instrumentation.testing.junit.db.SemconvStabilityUtil.maybeStable; import static io.opentelemetry.instrumentation.testing.junit.service.SemconvServiceStabilityUtil.maybeStablePeerService; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; import static io.opentelemetry.semconv.ServerAttributes.SERVER_PORT; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_OPERATION; @@ -18,18 +19,27 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM_NAME; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.REDIS; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.instrumentation.testing.internal.AutoCleanupExtension; import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension; import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; +import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.GenericContainer; +import redis.clients.jedis.Client; import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPipeline; @SuppressWarnings("deprecation") // using deprecated semconv public abstract class AbstractJedisTest { @@ -162,4 +172,119 @@ void commandWithNoArguments() { equalTo(SERVER_ADDRESS, host), equalTo(SERVER_PORT, port)))); } + + // Jedis pipelines are traced as one aggregate span from queueing through completion. + @ParameterizedTest(name = "{0}") + @MethodSource("pipelineScenarios") + void pipelineCommand( + String name, PipelineScenario scenario, List expectedCommands) { + jedis.pipelined( + new JedisPipeline() { + @Override + public void execute() { + scenario.run(client); + } + }); + + if (expectedCommands.isEmpty()) { + assertThat(testing.spans()).isEmpty(); + return; + } + + String operation = pipelineOperation(expectedCommands); + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName( + emitStableDatabaseSemconv() + ? operation + " " + host + ":" + port + : operation) + .hasKind(SpanKind.CLIENT) + .hasAttributesSatisfyingExactly( + equalTo(maybeStable(DB_SYSTEM), REDIS), + equalTo(maybeStable(DB_STATEMENT), pipelineStatement(expectedCommands)), + equalTo(maybeStable(DB_OPERATION), operation), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() && expectedCommands.size() > 1 + ? (long) expectedCommands.size() + : null), + equalTo(maybeStablePeerService(), "test-peer-service"), + equalTo(SERVER_ADDRESS, host), + equalTo(SERVER_PORT, port)))); + } + + private static String pipelineOperation(List commands) { + if (commands.size() == 1) { + return commands.get(0).operation; + } + String operation = commands.get(0).operation; + for (ExpectedCommand command : commands) { + if (!operation.equals(command.operation)) { + return "PIPELINE"; + } + } + return "PIPELINE " + operation; + } + + private static String pipelineStatement(List commands) { + StringBuilder statement = new StringBuilder(); + for (ExpectedCommand command : commands) { + if (statement.length() > 0) { + statement.append(';'); + } + statement.append(command.statement); + } + return statement.toString(); + } + + private static Stream pipelineScenarios() { + return Stream.of( + Arguments.of("empty", (PipelineScenario) pipeline -> {}, emptyList()), + Arguments.of( + "single", + (PipelineScenario) pipeline -> pipeline.set("batch1", "v1"), + expectedCommands(expectedCommand("SET", "SET batch1 ?"))), + Arguments.of( + "twoSameOperation", + (PipelineScenario) + pipeline -> { + pipeline.set("batch1", "v1"); + pipeline.set("batch2", "v2"); + }, + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("SET", "SET batch2 ?"))), + Arguments.of( + "twoDifferentOperations", + (PipelineScenario) + pipeline -> { + pipeline.set("batch1", "v1"); + pipeline.get("batch1"); + }, + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("GET", "GET batch1")))); + } + + private static List expectedCommands(ExpectedCommand... commands) { + return asList(commands); + } + + private static ExpectedCommand expectedCommand(String operation, String statement) { + return new ExpectedCommand(operation, statement); + } + + private interface PipelineScenario { + void run(Client client); + } + + private static class ExpectedCommand { + private final String operation; + private final String statement; + + private ExpectedCommand(String operation, String statement) { + this.operation = operation; + this.statement = statement; + } + } } diff --git a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisConnectionInstrumentation.java b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisConnectionInstrumentation.java index be05abc041ae..2af93cb6b786 100644 --- a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisConnectionInstrumentation.java +++ b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisConnectionInstrumentation.java @@ -17,6 +17,7 @@ import io.opentelemetry.context.Scope; import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisPipelineContext; import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisRequestContext; import javax.annotation.Nullable; import net.bytebuddy.asm.Advice; @@ -60,6 +61,9 @@ public static AdviceScope start( Connection connection, ProtocolCommand command, byte[][] args) { Context parentContext = currentContext(); JedisRequest request = JedisRequest.create(connection, command, asList(args)); + if (JedisPipelineContext.capture(request)) { + return null; + } if (!instrumenter().shouldStart(parentContext, request)) { return null; } diff --git a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisDbAttributesGetter.java b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisDbAttributesGetter.java index 4749ca13f5de..9d7f92ad815e 100644 --- a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisDbAttributesGetter.java +++ b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisDbAttributesGetter.java @@ -34,6 +34,12 @@ public String getDbOperationName(JedisRequest request) { return request.getOperationName(); } + @Override + @Nullable + public Long getDbOperationBatchSize(JedisRequest request) { + return request.getBatchSize(); + } + @Override public String getServerAddress(JedisRequest request) { return request.getConnection().getHost(); diff --git a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisInstrumentationModule.java b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisInstrumentationModule.java index accd17ae7ab0..2bfca74f7f42 100644 --- a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisInstrumentationModule.java +++ b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisInstrumentationModule.java @@ -32,6 +32,9 @@ public ElementMatcher.Junction classLoaderMatcher() { @Override public List typeInstrumentations() { - return asList(new JedisConnectionInstrumentation(), new JedisInstrumentation()); + return asList( + new JedisConnectionInstrumentation(), + new JedisInstrumentation(), + new JedisPipelineInstrumentation()); } } diff --git a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisPipelineInstrumentation.java b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisPipelineInstrumentation.java new file mode 100644 index 000000000000..36f6f6af1a9f --- /dev/null +++ b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisPipelineInstrumentation.java @@ -0,0 +1,114 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.jedis.v3_0; + +import static io.opentelemetry.javaagent.bootstrap.Java8BytecodeBridge.currentContext; +import static io.opentelemetry.javaagent.instrumentation.jedis.v3_0.JedisSingletons.instrumenter; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.isPublic; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.namedOneOf; +import static net.bytebuddy.matcher.ElementMatchers.returns; + +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisPipelineContext; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +class JedisPipelineInstrumentation implements TypeInstrumentation { + @Override + public ElementMatcher typeMatcher() { + return namedOneOf( + "redis.clients.jedis.Pipeline", + "redis.clients.jedis.PipelineBase", + "redis.clients.jedis.MultiKeyPipelineBase"); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + isPublic().and(isMethod()).and(returns(named("redis.clients.jedis.Response"))), + getClass().getName() + "$QueueCommandAdvice"); + transformer.applyAdviceToMethod( + namedOneOf("sync", "syncAndReturnAll"), getClass().getName() + "$SyncAdvice"); + } + + @SuppressWarnings("unused") + public static class QueueCommandAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static void onEnter(@Advice.This Object pipeline) { + JedisPipelineContext.enter(pipeline); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void stopCollecting() { + JedisPipelineContext.exit(); + } + } + + @SuppressWarnings("unused") + public static class SyncAdvice { + + public static class AdviceScope { + private final Context context; + private final Scope scope; + private final JedisRequest request; + + private AdviceScope(Context context, Scope scope, JedisRequest request) { + this.context = context; + this.scope = scope; + this.request = request; + } + + @Nullable + public static AdviceScope start(Object pipeline) { + List capturedRequests = JedisPipelineContext.drain(pipeline); + if (capturedRequests.isEmpty()) { + return null; + } + List requests = new ArrayList<>(capturedRequests.size()); + for (Object capturedRequest : capturedRequests) { + requests.add((JedisRequest) capturedRequest); + } + JedisRequest request = JedisRequest.createPipeline(requests); + Context parentContext = currentContext(); + if (!instrumenter().shouldStart(parentContext, request)) { + return null; + } + Context context = instrumenter().start(parentContext, request); + return new AdviceScope(context, context.makeCurrent(), request); + } + + public void end(@Nullable Throwable throwable) { + scope.close(); + instrumenter().end(context, request, null, throwable); + } + } + + @Nullable + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static AdviceScope onEnter(@Advice.This Object pipeline) { + return AdviceScope.start(pipeline); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void stopSpan( + @Advice.Thrown @Nullable Throwable throwable, + @Advice.Enter @Nullable AdviceScope adviceScope) { + if (adviceScope != null) { + adviceScope.end(throwable); + } + } + } +} diff --git a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisRequest.java b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisRequest.java index 5c38108d134a..2126884145c2 100644 --- a/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisRequest.java +++ b/instrumentation/jedis/jedis-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/JedisRequest.java @@ -6,12 +6,16 @@ package io.opentelemetry.javaagent.instrumentation.jedis.v3_0; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyList; import com.google.auto.value.AutoValue; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.instrumentation.api.incubator.config.internal.DbConfig; import io.opentelemetry.instrumentation.api.incubator.semconv.db.RedisCommandSanitizer; +import java.util.ArrayList; import java.util.List; +import java.util.StringJoiner; +import javax.annotation.Nullable; import redis.clients.jedis.Connection; import redis.clients.jedis.Protocol; import redis.clients.jedis.commands.ProtocolCommand; @@ -25,27 +29,80 @@ public abstract class JedisRequest { public static JedisRequest create( Connection connection, ProtocolCommand command, List args) { - return new AutoValue_JedisRequest(connection, command, args); + return new AutoValue_JedisRequest(connection, command, args, null, null, null); + } + + public static JedisRequest createPipeline(List requests) { + JedisRequest first = requests.get(0); + return new AutoValue_JedisRequest( + first.getConnection(), + null, + emptyList(), + pipelineOperationName(requests), + pipelineQueryText(requests), + requests.size() > 1 ? (long) requests.size() : null); } public abstract Connection getConnection(); + @Nullable public abstract ProtocolCommand getCommand(); public abstract List getArgs(); + @Nullable + abstract String getOperationNameOverride(); + + @Nullable + abstract String getQueryTextOverride(); + + @Nullable + public abstract Long getBatchSize(); + public String getOperationName() { + String operationName = getOperationNameOverride(); + if (operationName != null) { + return operationName; + } ProtocolCommand command = getCommand(); if (command instanceof Protocol.Command) { return ((Protocol.Command) command).name(); - } else { - // Protocol.Command is the only implementation in the Jedis lib as of 3.1 but this will save - // us if that changes - return new String(command.getRaw(), UTF_8); } + // Protocol.Command is the only implementation in the Jedis lib as of 3.1 but this will save + // us if that changes + return new String(command.getRaw(), UTF_8); } public String getQueryText() { + String queryText = getQueryTextOverride(); + if (queryText != null) { + return queryText; + } return sanitizer.sanitize(getOperationName(), getArgs()); } + + private static String pipelineOperationName(List requests) { + if (requests.size() == 1) { + return requests.get(0).getOperationName(); + } + String commonOperationName = requests.get(0).getOperationName(); + for (int i = 1; i < requests.size(); i++) { + if (!commonOperationName.equals(requests.get(i).getOperationName())) { + return "PIPELINE"; + } + } + return "PIPELINE " + commonOperationName; + } + + private static String pipelineQueryText(List requests) { + StringJoiner joiner = new StringJoiner(";"); + List queryTexts = new ArrayList<>(requests.size()); + for (JedisRequest request : requests) { + queryTexts.add(request.getQueryText()); + } + for (String queryText : queryTexts) { + joiner.add(queryText); + } + return joiner.toString(); + } } diff --git a/instrumentation/jedis/jedis-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/Jedis30ClientTest.java b/instrumentation/jedis/jedis-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/Jedis30ClientTest.java index 14c4bfaf9770..81f32cbf5028 100644 --- a/instrumentation/jedis/jedis-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/Jedis30ClientTest.java +++ b/instrumentation/jedis/jedis-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/jedis/v3_0/Jedis30ClientTest.java @@ -12,6 +12,7 @@ import static io.opentelemetry.instrumentation.testing.junit.service.SemconvServiceStabilityUtil.maybeStablePeerService; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_NAME; import static io.opentelemetry.semconv.DbAttributes.DB_SYSTEM_NAME; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_PEER_ADDRESS; @@ -24,6 +25,8 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_STATEMENT; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.REDIS; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import io.opentelemetry.api.trace.SpanKind; @@ -32,13 +35,19 @@ import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; import java.net.InetAddress; import java.net.UnknownHostException; +import java.util.List; +import java.util.stream.Stream; import org.assertj.core.api.AbstractLongAssert; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.GenericContainer; import redis.clients.jedis.Jedis; +import redis.clients.jedis.Pipeline; @SuppressWarnings("deprecation") // using deprecated semconv class Jedis30ClientTest { @@ -191,4 +200,118 @@ void commandWithNoArguments() { equalTo(NETWORK_PEER_ADDRESS, ip), satisfies(NETWORK_PEER_PORT, AbstractLongAssert::isNotNegative)))); } + + // Jedis pipelines are traced as one aggregate span from queueing through sync completion. + @ParameterizedTest(name = "{0}") + @MethodSource("pipelineScenarios") + void pipelineCommand( + String name, PipelineScenario scenario, List expectedCommands) { + Pipeline pipeline = jedis.pipelined(); + scenario.run(pipeline); + pipeline.sync(); + + if (expectedCommands.isEmpty()) { + assertThat(testing.spans()).isEmpty(); + return; + } + + String operation = pipelineOperation(expectedCommands); + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName( + emitStableDatabaseSemconv() + ? operation + " " + host + ":" + port + : operation) + .hasKind(SpanKind.CLIENT) + .hasAttributesSatisfyingExactly( + equalTo(maybeStable(DB_SYSTEM), REDIS), + equalTo(maybeStable(DB_STATEMENT), pipelineStatement(expectedCommands)), + equalTo(maybeStable(DB_OPERATION), operation), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() && expectedCommands.size() > 1 + ? (long) expectedCommands.size() + : null), + equalTo(maybeStablePeerService(), "test-peer-service"), + equalTo(SERVER_ADDRESS, host), + equalTo(SERVER_PORT, port), + equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), + equalTo(NETWORK_PEER_ADDRESS, ip), + satisfies(NETWORK_PEER_PORT, AbstractLongAssert::isNotNegative)))); + } + + private static String pipelineOperation(List commands) { + if (commands.size() == 1) { + return commands.get(0).operation; + } + String operation = commands.get(0).operation; + for (ExpectedCommand command : commands) { + if (!operation.equals(command.operation)) { + return "PIPELINE"; + } + } + return "PIPELINE " + operation; + } + + private static String pipelineStatement(List commands) { + StringBuilder statement = new StringBuilder(); + for (ExpectedCommand command : commands) { + if (statement.length() > 0) { + statement.append(';'); + } + statement.append(command.statement); + } + return statement.toString(); + } + + private static Stream pipelineScenarios() { + return Stream.of( + Arguments.of("empty", (PipelineScenario) pipeline -> {}, emptyList()), + Arguments.of( + "single", + (PipelineScenario) pipeline -> pipeline.set("batch1", "v1"), + expectedCommands(expectedCommand("SET", "SET batch1 ?"))), + Arguments.of( + "twoSameOperation", + (PipelineScenario) + pipeline -> { + pipeline.set("batch1", "v1"); + pipeline.set("batch2", "v2"); + }, + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("SET", "SET batch2 ?"))), + Arguments.of( + "twoDifferentOperations", + (PipelineScenario) + pipeline -> { + pipeline.set("batch1", "v1"); + pipeline.get("batch1"); + }, + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("GET", "GET batch1")))); + } + + private static List expectedCommands(ExpectedCommand... commands) { + return asList(commands); + } + + private static ExpectedCommand expectedCommand(String operation, String statement) { + return new ExpectedCommand(operation, statement); + } + + private interface PipelineScenario { + void run(Pipeline pipeline); + } + + private static class ExpectedCommand { + private final String operation; + private final String statement; + + private ExpectedCommand(String operation, String statement) { + this.operation = operation; + this.statement = statement; + } + } } diff --git a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisConnectionInstrumentation.java b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisConnectionInstrumentation.java index b507824a7d6c..bb78f9ffd1bd 100644 --- a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisConnectionInstrumentation.java +++ b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisConnectionInstrumentation.java @@ -19,6 +19,7 @@ import io.opentelemetry.context.Scope; import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisPipelineContext; import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisRequestContext; import java.net.Socket; import javax.annotation.Nullable; @@ -66,8 +67,12 @@ private AdviceScope(Context context, Scope scope, JedisRequest request) { } @Nullable - public static AdviceScope start(JedisRequest request) { + public static AdviceScope start(JedisRequest request, @Nullable Socket socket) { Context parentContext = currentContext(); + request.setSocket(socket); + if (JedisPipelineContext.capture(request)) { + return null; + } if (!instrumenter().shouldStart(parentContext, request)) { return null; } @@ -101,8 +106,9 @@ public static class SendCommandAdvice { public static AdviceScope onEnter( @Advice.This Connection connection, @Advice.Argument(0) ProtocolCommand command, - @Advice.Argument(1) byte[][] args) { - return AdviceScope.start(JedisRequest.create(connection, command, asList(args))); + @Advice.Argument(1) byte[][] args, + @Advice.FieldValue("socket") Socket socket) { + return AdviceScope.start(JedisRequest.create(connection, command, asList(args)), socket); } @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) @@ -122,8 +128,10 @@ public static class SendCommand2Advice { @Nullable @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) public static AdviceScope onEnter( - @Advice.This Connection connection, @Advice.Argument(0) CommandArguments command) { - return AdviceScope.start(JedisRequest.create(connection, command)); + @Advice.This Connection connection, + @Advice.Argument(0) CommandArguments command, + @Advice.FieldValue("socket") Socket socket) { + return AdviceScope.start(JedisRequest.create(connection, command), socket); } @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) diff --git a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisDbAttributesGetter.java b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisDbAttributesGetter.java index 5fbe73b2da64..3f3b9aca9d7d 100644 --- a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisDbAttributesGetter.java +++ b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisDbAttributesGetter.java @@ -41,6 +41,12 @@ public String getDbOperationName(JedisRequest request) { return request.getOperationName(); } + @Override + @Nullable + public Long getDbOperationBatchSize(JedisRequest request) { + return request.getBatchSize(); + } + @Override @Nullable public String getServerAddress(JedisRequest request) { diff --git a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisInstrumentationModule.java b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisInstrumentationModule.java index c7395eff1a83..c857474c746c 100644 --- a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisInstrumentationModule.java +++ b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisInstrumentationModule.java @@ -32,7 +32,10 @@ public ElementMatcher.Junction classLoaderMatcher() { @Override public List typeInstrumentations() { - return asList(new JedisConnectionInstrumentation(), new JedisInstrumentation()); + return asList( + new JedisConnectionInstrumentation(), + new JedisInstrumentation(), + new JedisPipelineInstrumentation()); } @Override diff --git a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisPipelineInstrumentation.java b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisPipelineInstrumentation.java new file mode 100644 index 000000000000..3715bfc114b3 --- /dev/null +++ b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisPipelineInstrumentation.java @@ -0,0 +1,108 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.jedis.v4_0; + +import static io.opentelemetry.javaagent.bootstrap.Java8BytecodeBridge.currentContext; +import static io.opentelemetry.javaagent.instrumentation.jedis.v4_0.JedisSingletons.instrumenter; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.namedOneOf; + +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisPipelineContext; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +class JedisPipelineInstrumentation implements TypeInstrumentation { + @Override + public ElementMatcher typeMatcher() { + return namedOneOf("redis.clients.jedis.Pipeline", "redis.clients.jedis.PipelineBase"); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + named("appendCommand"), getClass().getName() + "$QueueCommandAdvice"); + transformer.applyAdviceToMethod( + namedOneOf("sync", "syncAndReturnAll"), getClass().getName() + "$SyncAdvice"); + } + + @SuppressWarnings("unused") + public static class QueueCommandAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static void onEnter(@Advice.This Object pipeline) { + JedisPipelineContext.enter(pipeline); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void stopCollecting() { + JedisPipelineContext.exit(); + } + } + + @SuppressWarnings("unused") + public static class SyncAdvice { + + public static class AdviceScope { + private final Context context; + private final Scope scope; + private final JedisRequest request; + + private AdviceScope(Context context, Scope scope, JedisRequest request) { + this.context = context; + this.scope = scope; + this.request = request; + } + + @Nullable + public static AdviceScope start(Object pipeline) { + List capturedRequests = JedisPipelineContext.drain(pipeline); + if (capturedRequests.isEmpty()) { + return null; + } + List requests = new ArrayList<>(capturedRequests.size()); + for (Object capturedRequest : capturedRequests) { + requests.add((JedisRequest) capturedRequest); + } + JedisRequest request = JedisRequest.createPipeline(requests); + Context parentContext = currentContext(); + if (!instrumenter().shouldStart(parentContext, request)) { + return null; + } + Context context = instrumenter().start(parentContext, request); + return new AdviceScope(context, context.makeCurrent(), request); + } + + public void end(@Nullable Throwable throwable) { + scope.close(); + request.setSocket(null); + instrumenter().end(context, request, null, throwable); + } + } + + @Nullable + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static AdviceScope onEnter(@Advice.This Object pipeline) { + return AdviceScope.start(pipeline); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void stopSpan( + @Advice.Thrown @Nullable Throwable throwable, + @Advice.Enter @Nullable AdviceScope adviceScope) { + if (adviceScope != null) { + adviceScope.end(throwable); + } + } + } +} diff --git a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisRequest.java b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisRequest.java index e54859c328ab..42ea72d26a53 100644 --- a/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisRequest.java +++ b/instrumentation/jedis/jedis-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/JedisRequest.java @@ -6,6 +6,7 @@ package io.opentelemetry.javaagent.instrumentation.jedis.v4_0; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyList; import com.google.auto.value.AutoValue; import io.opentelemetry.api.GlobalOpenTelemetry; @@ -15,6 +16,7 @@ import java.net.SocketAddress; import java.util.ArrayList; import java.util.List; +import java.util.StringJoiner; import javax.annotation.Nullable; import redis.clients.jedis.CommandArguments; import redis.clients.jedis.Connection; @@ -43,7 +45,10 @@ public static JedisRequest create( args, connectionInfo != null ? connectionInfo.getServerAddress() : null, connectionInfo != null ? connectionInfo.getServerPort() : null, - connectionInfo != null ? connectionInfo.getDatabaseIndex() : null); + connectionInfo != null ? connectionInfo.getDatabaseIndex() : null, + null, + null, + null); } public static JedisRequest create(CommandArguments commandArguments) { @@ -65,6 +70,22 @@ public static JedisRequest create( return create(connection, command, arguments); } + public static JedisRequest createPipeline(List requests) { + JedisRequest first = requests.get(0); + JedisRequest request = + new AutoValue_JedisRequest( + null, + emptyList(), + first.getServerAddress(), + first.getServerPort(), + first.getDatabaseIndex(), + pipelineOperationName(requests), + pipelineQueryText(requests), + requests.size() > 1 ? (long) requests.size() : null); + request.remoteSocketAddress = first.getRemoteSocketAddress(); + return request; + } + @Nullable private static JedisConnectionInfo getConnectionInfo(@Nullable Object connection) { return connection instanceof Connection @@ -72,6 +93,7 @@ private static JedisConnectionInfo getConnectionInfo(@Nullable Object connection : null; } + @Nullable public abstract ProtocolCommand getCommand(); public abstract List getArgs(); @@ -85,21 +107,58 @@ private static JedisConnectionInfo getConnectionInfo(@Nullable Object connection @Nullable public abstract Long getDatabaseIndex(); + @Nullable + abstract String getOperationNameOverride(); + + @Nullable + abstract String getQueryTextOverride(); + + @Nullable + public abstract Long getBatchSize(); + public String getOperationName() { + String operationName = getOperationNameOverride(); + if (operationName != null) { + return operationName; + } ProtocolCommand command = getCommand(); if (command instanceof Protocol.Command) { return ((Protocol.Command) command).name(); - } else { - // Protocol.Command is the only implementation in the Jedis lib as of 3.1 but this will save - // us if that changes - return new String(command.getRaw(), UTF_8); } + // Protocol.Command is the only implementation in the Jedis lib as of 3.1 but this will save + // us if that changes + return new String(command.getRaw(), UTF_8); } public String getQueryText() { + String queryText = getQueryTextOverride(); + if (queryText != null) { + return queryText; + } return sanitizer.sanitize(getOperationName(), getArgs()); } + private static String pipelineOperationName(List requests) { + if (requests.size() == 1) { + return requests.get(0).getOperationName(); + } + String commonOperationName = requests.get(0).getOperationName(); + for (int i = 1; i < requests.size(); i++) { + if (!commonOperationName.equals(requests.get(i).getOperationName())) { + return "PIPELINE"; + } + } + return "PIPELINE " + commonOperationName; + } + + private static String pipelineQueryText(List requests) { + StringJoiner joiner = new StringJoiner(";"); + for (JedisRequest request : requests) { + joiner.add(request.getQueryText()); + } + return joiner.toString(); + } + public void setSocket(@Nullable Socket socket) { if (socket != null) { remoteSocketAddress = socket.getRemoteSocketAddress(); diff --git a/instrumentation/jedis/jedis-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/Jedis40ClientTest.java b/instrumentation/jedis/jedis-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/Jedis40ClientTest.java index 00b868ff5eea..5d19a526b183 100644 --- a/instrumentation/jedis/jedis-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/Jedis40ClientTest.java +++ b/instrumentation/jedis/jedis-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/jedis/v4_0/Jedis40ClientTest.java @@ -11,6 +11,7 @@ import static io.opentelemetry.instrumentation.testing.junit.db.SemconvStabilityUtil.maybeStable; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; import static io.opentelemetry.semconv.DbAttributes.DB_NAMESPACE; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_PEER_ADDRESS; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_PEER_PORT; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_TYPE; @@ -23,6 +24,8 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM_NAME; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.REDIS; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import io.opentelemetry.api.trace.SpanKind; @@ -31,14 +34,20 @@ import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; import java.net.InetAddress; import java.net.UnknownHostException; +import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.GenericContainer; import redis.clients.jedis.DefaultJedisClientConfig; import redis.clients.jedis.HostAndPort; import redis.clients.jedis.Jedis; +import redis.clients.jedis.Pipeline; @SuppressWarnings("deprecation") // using deprecated semconv class Jedis40ClientTest { @@ -258,4 +267,118 @@ void configuredDatabaseIndex() { equalTo(NETWORK_PEER_PORT, port), equalTo(NETWORK_PEER_ADDRESS, ip)))); } + + // Jedis pipelines are traced as one aggregate span from queueing through sync completion. + @ParameterizedTest(name = "{0}") + @MethodSource("pipelineScenarios") + void pipelineCommand( + String name, PipelineScenario scenario, List expectedCommands) { + Pipeline pipeline = jedis.pipelined(); + scenario.run(pipeline); + pipeline.sync(); + + if (expectedCommands.isEmpty()) { + assertThat(testing.spans()).isEmpty(); + return; + } + + String operation = pipelineOperation(expectedCommands); + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName( + emitStableDatabaseSemconv() + ? operation + " " + host + ":" + port + : operation) + .hasKind(SpanKind.CLIENT) + .hasAttributesSatisfyingExactly( + equalTo(maybeStable(DB_SYSTEM), REDIS), + equalTo(maybeStable(DB_STATEMENT), pipelineStatement(expectedCommands)), + equalTo(maybeStable(DB_OPERATION), operation), + equalTo(DB_NAMESPACE, emitStableDatabaseSemconv() ? "0" : null), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() && expectedCommands.size() > 1 + ? (long) expectedCommands.size() + : null), + equalTo(SERVER_ADDRESS, host), + equalTo(SERVER_PORT, port), + equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), + equalTo(NETWORK_PEER_PORT, port), + equalTo(NETWORK_PEER_ADDRESS, ip)))); + } + + private static String pipelineOperation(List commands) { + if (commands.size() == 1) { + return commands.get(0).operation; + } + String operation = commands.get(0).operation; + for (ExpectedCommand command : commands) { + if (!operation.equals(command.operation)) { + return "PIPELINE"; + } + } + return "PIPELINE " + operation; + } + + private static String pipelineStatement(List commands) { + StringBuilder statement = new StringBuilder(); + for (ExpectedCommand command : commands) { + if (statement.length() > 0) { + statement.append(';'); + } + statement.append(command.statement); + } + return statement.toString(); + } + + private static Stream pipelineScenarios() { + return Stream.of( + Arguments.of("empty", (PipelineScenario) pipeline -> {}, emptyList()), + Arguments.of( + "single", + (PipelineScenario) pipeline -> pipeline.set("batch1", "v1"), + expectedCommands(expectedCommand("SET", "SET batch1 ?"))), + Arguments.of( + "twoSameOperation", + (PipelineScenario) + pipeline -> { + pipeline.set("batch1", "v1"); + pipeline.set("batch2", "v2"); + }, + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("SET", "SET batch2 ?"))), + Arguments.of( + "twoDifferentOperations", + (PipelineScenario) + pipeline -> { + pipeline.set("batch1", "v1"); + pipeline.get("batch1"); + }, + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("GET", "GET batch1")))); + } + + private static List expectedCommands(ExpectedCommand... commands) { + return asList(commands); + } + + private static ExpectedCommand expectedCommand(String operation, String statement) { + return new ExpectedCommand(operation, statement); + } + + private interface PipelineScenario { + void run(Pipeline pipeline); + } + + private static class ExpectedCommand { + private final String operation; + private final String statement; + + private ExpectedCommand(String operation, String statement) { + this.operation = operation; + this.statement = statement; + } + } } diff --git a/instrumentation/jedis/jedis-common-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/common/v1_4/JedisPipelineContext.java b/instrumentation/jedis/jedis-common-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/common/v1_4/JedisPipelineContext.java new file mode 100644 index 000000000000..fcfa2fad53aa --- /dev/null +++ b/instrumentation/jedis/jedis-common-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/common/v1_4/JedisPipelineContext.java @@ -0,0 +1,50 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4; + +import static java.util.Collections.emptyList; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import javax.annotation.Nullable; + +public final class JedisPipelineContext { + private static final ThreadLocal currentPipeline = new ThreadLocal<>(); + private static final Map> requestsByPipeline = + Collections.synchronizedMap(new WeakHashMap<>()); + + public static void enter(Object pipeline) { + currentPipeline.set(pipeline); + } + + public static void exit() { + currentPipeline.remove(); + } + + public static boolean capture(Object request) { + Object pipeline = currentPipeline.get(); + if (pipeline == null) { + return false; + } + requestsByPipeline.computeIfAbsent(pipeline, unused -> new ArrayList<>()).add(request); + return true; + } + + public static List drain(Object pipeline) { + List requests = requestsByPipeline.remove(pipeline); + return requests != null ? requests : emptyList(); + } + + @Nullable + public static Object currentPipeline() { + return currentPipeline.get(); + } + + private JedisPipelineContext() {} +} diff --git a/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncCommandsInstrumentation.java b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncCommandsInstrumentation.java index 02d397008b57..126bdae727c3 100644 --- a/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncCommandsInstrumentation.java +++ b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncCommandsInstrumentation.java @@ -10,6 +10,7 @@ import static io.opentelemetry.javaagent.instrumentation.lettuce.v4_0.LettuceSingletons.instrumenter; import static net.bytebuddy.matcher.ElementMatchers.named; import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; import com.lambdaworks.redis.protocol.AsyncCommand; import com.lambdaworks.redis.protocol.RedisCommand; @@ -35,24 +36,53 @@ public void transform(TypeTransformer transformer) { named("dispatch") .and(takesArgument(0, named("com.lambdaworks.redis.protocol.RedisCommand"))), getClass().getName() + "$DispatchAdvice"); + transformer.applyAdviceToMethod( + named("setAutoFlushCommands").and(takesArguments(1)), + getClass().getName() + "$SetAutoFlushAdvice"); + transformer.applyAdviceToMethod( + named("flushCommands").and(takesArguments(0)), getClass().getName() + "$FlushAdvice"); } @SuppressWarnings("unused") public static class DispatchAdvice { public static class AdviceScope { - private final Context context; - private final Scope scope; + private final Object commands; + @Nullable private final Context context; + @Nullable private final Scope scope; + private final boolean captured; - public AdviceScope(Context context, Scope scope) { + public AdviceScope( + Object commands, @Nullable Context context, @Nullable Scope scope, boolean captured) { + this.commands = commands; this.context = context; this.scope = scope; + this.captured = captured; + } + + public static AdviceScope captured(Object commands) { + Context parentContext = currentContext(); + Context context = parentContext.with(COMMAND_CONTEXT_KEY, parentContext); + return new AdviceScope(commands, null, context.makeCurrent(), true); } public void end( @Nullable Throwable throwable, RedisCommand command, @Nullable AsyncCommand asyncCommand) { + if (captured) { + try { + LettuceBatchContext.capture(commands, command, asyncCommand); + } finally { + if (scope != null) { + scope.close(); + } + } + return; + } + if (scope == null || context == null) { + return; + } scope.close(); InstrumentationPoints.afterCommand(command, context, throwable, asyncCommand); } @@ -60,7 +90,11 @@ public void end( @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) @Nullable - public static AdviceScope onEnter(@Advice.Argument(0) RedisCommand command) { + public static AdviceScope onEnter( + @Advice.This Object commands, @Advice.Argument(0) RedisCommand command) { + if (LettuceBatchContext.isCollecting(commands)) { + return AdviceScope.captured(commands); + } Context parentContext = currentContext(); if (!instrumenter().shouldStart(parentContext, command)) { @@ -70,7 +104,7 @@ public static AdviceScope onEnter(@Advice.Argument(0) RedisCommand comm Context context = instrumenter().start(parentContext, command); // remember the context that called dispatch, it is used in LettuceAsyncCommandInstrumentation context = context.with(COMMAND_CONTEXT_KEY, parentContext); - return new AdviceScope(context, context.makeCurrent()); + return new AdviceScope(commands, context, context.makeCurrent(), false); } @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) @@ -84,4 +118,49 @@ public static void onExit( } } } + + @SuppressWarnings("unused") + public static class SetAutoFlushAdvice { + + @Advice.OnMethodExit(suppress = Throwable.class, inline = false) + public static void onExit(@Advice.This Object commands, @Advice.Argument(0) boolean autoFlush) { + LettuceBatchContext.setCollecting(commands, !autoFlush); + } + } + + @SuppressWarnings("unused") + public static class FlushAdvice { + + public static class FlushAdviceScope { + @Nullable private final LettuceBatchContext.BatchScope batchScope; + + private FlushAdviceScope(@Nullable LettuceBatchContext.BatchScope batchScope) { + this.batchScope = batchScope; + } + + public static FlushAdviceScope start(Object commands) { + return new FlushAdviceScope(LettuceBatchContext.start(commands)); + } + + public void end(@Nullable Throwable throwable) { + if (throwable != null && batchScope != null) { + batchScope.endOne(throwable); + } + } + } + + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static FlushAdviceScope onEnter(@Advice.This Object commands) { + return FlushAdviceScope.start(commands); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void onExit( + @Advice.Thrown @Nullable Throwable throwable, + @Advice.Enter @Nullable FlushAdviceScope adviceScope) { + if (adviceScope != null) { + adviceScope.end(throwable); + } + } + } } diff --git a/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchAttributesGetter.java b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchAttributesGetter.java new file mode 100644 index 000000000000..1895a6da9629 --- /dev/null +++ b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchAttributesGetter.java @@ -0,0 +1,42 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.lettuce.v4_0; + +import io.opentelemetry.instrumentation.api.incubator.semconv.db.DbClientAttributesGetter; +import io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues; +import javax.annotation.Nullable; + +final class LettuceBatchAttributesGetter + implements DbClientAttributesGetter { + + @Override + public String getDbSystemName(LettuceBatchRequest request) { + return DbSystemNameIncubatingValues.REDIS; + } + + @Override + @Nullable + public String getDbNamespace(LettuceBatchRequest request) { + return null; + } + + @Override + @Nullable + public String getDbQueryText(LettuceBatchRequest request) { + return null; + } + + @Override + public String getDbOperationName(LettuceBatchRequest request) { + return request.getOperationName(); + } + + @Override + @Nullable + public Long getDbOperationBatchSize(LettuceBatchRequest request) { + return request.getBatchSize(); + } +} diff --git a/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchContext.java b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchContext.java new file mode 100644 index 000000000000..0652e4a3d235 --- /dev/null +++ b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchContext.java @@ -0,0 +1,148 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.lettuce.v4_0; + +import static io.opentelemetry.javaagent.bootstrap.Java8BytecodeBridge.currentContext; +import static io.opentelemetry.javaagent.instrumentation.lettuce.v4_0.LettuceSingletons.CONTEXT; +import static io.opentelemetry.javaagent.instrumentation.lettuce.v4_0.LettuceSingletons.batchInstrumenter; + +import com.lambdaworks.redis.protocol.AsyncCommand; +import com.lambdaworks.redis.protocol.RedisCommand; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.incubator.config.internal.DeclarativeConfigUtil; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nullable; + +public final class LettuceBatchContext { + private static final boolean CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES = + DeclarativeConfigUtil.getInstrumentationConfig(GlobalOpenTelemetry.get(), "lettuce") + .getBoolean("experimental_span_attributes/development", false); + + private static final Map states = + Collections.synchronizedMap(new WeakHashMap<>()); + + public static void setCollecting(Object commands, boolean collecting) { + if (collecting) { + states.put(commands, new BatchState()); + } else { + states.remove(commands); + } + } + + public static boolean isCollecting(Object commands) { + return states.containsKey(commands); + } + + public static boolean capture( + Object commands, + RedisCommand command, + @Nullable AsyncCommand asyncCommand) { + BatchState state = states.get(commands); + if (state == null) { + return false; + } + state.add(command, asyncCommand); + return true; + } + + @Nullable + public static BatchScope start(Object commands) { + BatchState state = states.get(commands); + if (state == null || state.commands.isEmpty()) { + return null; + } + return BatchScope.start(state.drainCommands(), state.drainAsyncCommands(), state.parentContext); + } + + private LettuceBatchContext() {} + + public static final class BatchScope { + private final Context context; + private final LettuceBatchRequest request; + private final AtomicInteger remaining; + + private BatchScope(Context context, LettuceBatchRequest request, int remaining) { + this.context = context; + this.request = request; + this.remaining = new AtomicInteger(remaining); + } + + @Nullable + private static BatchScope start( + List> commands, + List> asyncCommands, + @Nullable Context capturedParentContext) { + LettuceBatchRequest request = LettuceBatchRequest.create(commands); + Context parentContext = + capturedParentContext == null ? currentContext() : capturedParentContext; + if (!batchInstrumenter().shouldStart(parentContext, request)) { + return null; + } + Context context = batchInstrumenter().start(parentContext, request); + if (asyncCommands.isEmpty()) { + batchInstrumenter().end(context, request, null, null); + return null; + } + BatchScope scope = new BatchScope(context, request, asyncCommands.size()); + for (AsyncCommand asyncCommand : asyncCommands) { + asyncCommand.handleAsync( + (value, throwable) -> { + scope.endOne(throwable); + return null; + }); + } + return scope; + } + + public void endOne(@Nullable Throwable throwable) { + if (throwable instanceof CancellationException) { + if (CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES) { + Span.fromContext(context).setAttribute("lettuce.command.cancelled", true); + } + throwable = null; + } + if (remaining.getAndDecrement() == 1) { + batchInstrumenter().end(context, request, null, throwable); + } + } + } + + private static final class BatchState { + private final List> commands = new ArrayList<>(); + private final List> asyncCommands = new ArrayList<>(); + @Nullable private Context parentContext; + + private void add(RedisCommand command, @Nullable AsyncCommand asyncCommand) { + commands.add(command); + if (parentContext == null && asyncCommand != null) { + parentContext = CONTEXT.get(asyncCommand); + } + if (asyncCommand != null && InstrumentationPoints.expectsResponse(command)) { + asyncCommands.add(asyncCommand); + } + } + + private List> drainCommands() { + List> drainedCommands = new ArrayList<>(commands); + commands.clear(); + return drainedCommands; + } + + private List> drainAsyncCommands() { + List> drainedAsyncCommands = new ArrayList<>(asyncCommands); + asyncCommands.clear(); + return drainedAsyncCommands; + } + } +} diff --git a/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchRequest.java b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchRequest.java new file mode 100644 index 000000000000..d15aa8cd7c9e --- /dev/null +++ b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceBatchRequest.java @@ -0,0 +1,47 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.lettuce.v4_0; + +import com.lambdaworks.redis.protocol.RedisCommand; +import java.util.List; +import javax.annotation.Nullable; + +final class LettuceBatchRequest { + private final String operationName; + @Nullable private final Long batchSize; + + private LettuceBatchRequest(String operationName, @Nullable Long batchSize) { + this.operationName = operationName; + this.batchSize = batchSize; + } + + static LettuceBatchRequest create(List> commands) { + return new LettuceBatchRequest( + operationName(commands), commands.size() > 1 ? (long) commands.size() : null); + } + + String getOperationName() { + return operationName; + } + + @Nullable + Long getBatchSize() { + return batchSize; + } + + private static String operationName(List> commands) { + if (commands.size() == 1) { + return commands.get(0).getType().name(); + } + String operationName = commands.get(0).getType().name(); + for (int i = 1; i < commands.size(); i++) { + if (!operationName.equals(commands.get(i).getType().name())) { + return "PIPELINE"; + } + } + return "PIPELINE " + operationName; + } +} diff --git a/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceSingletons.java b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceSingletons.java index 13683c5aab13..6955e1fa538b 100644 --- a/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceSingletons.java +++ b/instrumentation/lettuce/lettuce-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceSingletons.java @@ -28,6 +28,7 @@ public class LettuceSingletons { private static final String INSTRUMENTATION_NAME = "io.opentelemetry.lettuce-4.0"; private static final Instrumenter, Void> instrumenter; + private static final Instrumenter batchInstrumenter; private static final Instrumenter connectInstrumenter; public static final ContextKey COMMAND_CONTEXT_KEY = @@ -50,6 +51,17 @@ public class LettuceSingletons { instrumenter = builder.buildInstrumenter(SpanKindExtractor.alwaysClient()); + LettuceBatchAttributesGetter batchAttributesGetter = new LettuceBatchAttributesGetter(); + InstrumenterBuilder batchBuilder = + Instrumenter.builder( + GlobalOpenTelemetry.get(), + INSTRUMENTATION_NAME, + DbClientSpanNameExtractor.create(batchAttributesGetter)) + .addAttributesExtractor(DbClientAttributesExtractor.create(batchAttributesGetter)) + .addOperationMetrics(DbClientMetrics.get()); + setDbClientExceptionEventExtractor(batchBuilder); + batchInstrumenter = batchBuilder.buildInstrumenter(SpanKindExtractor.alwaysClient()); + LettuceConnectNetworkAttributesGetter netAttributesGetter = new LettuceConnectNetworkAttributesGetter(); @@ -72,6 +84,10 @@ public class LettuceSingletons { return instrumenter; } + public static Instrumenter batchInstrumenter() { + return batchInstrumenter; + } + public static Instrumenter connectInstrumenter() { return connectInstrumenter; } diff --git a/instrumentation/lettuce/lettuce-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java b/instrumentation/lettuce/lettuce-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java index 494063d5006d..d925fa692d8b 100644 --- a/instrumentation/lettuce/lettuce-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java +++ b/instrumentation/lettuce/lettuce-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java @@ -11,6 +11,7 @@ import static io.opentelemetry.instrumentation.testing.junit.service.SemconvServiceStabilityUtil.maybeStablePeerService; import static io.opentelemetry.javaagent.instrumentation.lettuce.v4_0.ExperimentalHelper.experimental; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; import static io.opentelemetry.semconv.ServerAttributes.SERVER_PORT; @@ -18,6 +19,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.REDIS; import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchException; @@ -52,9 +54,13 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.containers.GenericContainer; @@ -476,6 +482,99 @@ void testCommandBeforeItFinished() { .hasParent(trace.getSpan(0)))); } + // Lettuce auto-flush batching is traced as one aggregate span from flush through completion. + @ParameterizedTest(name = "{0}") + @MethodSource("deferredFlushScenarios") + void deferredFlushCommand( + String name, AsyncCommandsScenario scenario, List expectedCommands) + throws Exception { + asyncCommands.setAutoFlushCommands(false); + cleanup.deferCleanup(() -> asyncCommands.setAutoFlushCommands(true)); + + List> futures = scenario.run(asyncCommands); + asyncCommands.flushCommands(); + for (RedisFuture future : futures) { + future.get(10, SECONDS); + } + + if (expectedCommands.isEmpty()) { + assertThat(testing.spans()).isEmpty(); + return; + } + + String operation = pipelineOperation(expectedCommands); + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName(operation) + .hasKind(SpanKind.CLIENT) + .hasAttributesSatisfyingExactly( + equalTo(maybeStable(DB_SYSTEM), REDIS), + equalTo(maybeStable(DB_OPERATION), operation), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() && expectedCommands.size() > 1 + ? (long) expectedCommands.size() + : null)))); + } + + private static String pipelineOperation(List commands) { + if (commands.size() == 1) { + return commands.get(0).operation; + } + String operation = commands.get(0).operation; + for (ExpectedCommand command : commands) { + if (!operation.equals(command.operation)) { + return "PIPELINE"; + } + } + return "PIPELINE " + operation; + } + + private static Stream deferredFlushScenarios() { + return Stream.of( + Arguments.of("empty", (AsyncCommandsScenario) commands -> emptyList(), emptyList()), + Arguments.of( + "single", + (AsyncCommandsScenario) commands -> futures(commands.set("batch1", "v1")), + expectedCommands(expectedCommand("SET"))), + Arguments.of( + "twoSameOperation", + (AsyncCommandsScenario) + commands -> futures(commands.set("batch1", "v1"), commands.set("batch2", "v2")), + expectedCommands(expectedCommand("SET"), expectedCommand("SET"))), + Arguments.of( + "twoDifferentOperations", + (AsyncCommandsScenario) + commands -> futures(commands.set("batch1", "v1"), commands.get("batch1")), + expectedCommands(expectedCommand("SET"), expectedCommand("GET")))); + } + + private static List> futures(RedisFuture... futures) { + return asList(futures); + } + + private static List expectedCommands(ExpectedCommand... commands) { + return asList(commands); + } + + private static ExpectedCommand expectedCommand(String operation) { + return new ExpectedCommand(operation); + } + + private interface AsyncCommandsScenario { + List> run(RedisAsyncCommands commands); + } + + private static class ExpectedCommand { + private final String operation; + + private ExpectedCommand(String operation) { + this.operation = operation; + } + } + @Test void testDebugSegfaultCommandWithNoArgumentShouldProduceSpan() { // Test Causes redis to crash therefore it needs its own container diff --git a/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncCommandsInstrumentation.java b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncCommandsInstrumentation.java index 33f0c1384f28..7b25f4a75501 100644 --- a/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncCommandsInstrumentation.java +++ b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncCommandsInstrumentation.java @@ -11,6 +11,7 @@ import static io.opentelemetry.javaagent.instrumentation.lettuce.v5_0.LettuceSingletons.instrumenter; import static net.bytebuddy.matcher.ElementMatchers.named; import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; import io.lettuce.core.protocol.AsyncCommand; import io.lettuce.core.protocol.RedisCommand; @@ -35,24 +36,53 @@ public void transform(TypeTransformer transformer) { transformer.applyAdviceToMethod( named("dispatch").and(takesArgument(0, named("io.lettuce.core.protocol.RedisCommand"))), getClass().getName() + "$DispatchAdvice"); + transformer.applyAdviceToMethod( + named("setAutoFlushCommands").and(takesArguments(1)), + getClass().getName() + "$SetAutoFlushAdvice"); + transformer.applyAdviceToMethod( + named("flushCommands").and(takesArguments(0)), getClass().getName() + "$FlushAdvice"); } @SuppressWarnings("unused") public static class DispatchAdvice { public static class AdviceScope { - private final Context context; - private final Scope scope; - - public AdviceScope(Context context, Scope scope) { + private final Object commands; + @Nullable private final Context context; + @Nullable private final Scope scope; + private final boolean captured; + + public AdviceScope( + Object commands, @Nullable Context context, @Nullable Scope scope, boolean captured) { + this.commands = commands; this.context = context; this.scope = scope; + this.captured = captured; + } + + public static AdviceScope captured(Object commands) { + Context parentContext = currentContext(); + Context context = parentContext.with(COMMAND_CONTEXT_KEY, parentContext); + return new AdviceScope(commands, null, context.makeCurrent(), true); } public void end( @Nullable Throwable throwable, RedisCommand command, @Nullable AsyncCommand asyncCommand) { + if (captured) { + try { + LettuceBatchContext.capture(commands, command, asyncCommand); + } finally { + if (scope != null) { + scope.close(); + } + } + return; + } + if (scope == null || context == null) { + return; + } scope.close(); if (throwable != null || asyncCommand == null) { @@ -71,7 +101,11 @@ public void end( @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) @Nullable - public static AdviceScope onEnter(@Advice.Argument(0) RedisCommand command) { + public static AdviceScope onEnter( + @Advice.This Object commands, @Advice.Argument(0) RedisCommand command) { + if (LettuceBatchContext.isCollecting(commands)) { + return AdviceScope.captured(commands); + } Context parentContext = currentContext(); if (!instrumenter().shouldStart(parentContext, command)) { @@ -81,7 +115,7 @@ public static AdviceScope onEnter(@Advice.Argument(0) RedisCommand comm Context context = instrumenter().start(parentContext, command); // remember the context that called dispatch, it is used in LettuceAsyncCommandInstrumentation context = context.with(COMMAND_CONTEXT_KEY, parentContext); - return new AdviceScope(context, context.makeCurrent()); + return new AdviceScope(commands, context, context.makeCurrent(), false); } @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) @@ -96,4 +130,49 @@ public static void stopSpan( } } } + + @SuppressWarnings("unused") + public static class SetAutoFlushAdvice { + + @Advice.OnMethodExit(suppress = Throwable.class, inline = false) + public static void onExit(@Advice.This Object commands, @Advice.Argument(0) boolean autoFlush) { + LettuceBatchContext.setCollecting(commands, !autoFlush); + } + } + + @SuppressWarnings("unused") + public static class FlushAdvice { + + public static class FlushAdviceScope { + @Nullable private final LettuceBatchContext.BatchScope batchScope; + + private FlushAdviceScope(@Nullable LettuceBatchContext.BatchScope batchScope) { + this.batchScope = batchScope; + } + + public static FlushAdviceScope start(Object commands) { + return new FlushAdviceScope(LettuceBatchContext.start(commands)); + } + + public void end(@Nullable Throwable throwable) { + if (throwable != null && batchScope != null) { + batchScope.endOne(throwable); + } + } + } + + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static FlushAdviceScope onEnter(@Advice.This Object commands) { + return FlushAdviceScope.start(commands); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void onExit( + @Advice.Thrown @Nullable Throwable throwable, + @Advice.Enter @Nullable FlushAdviceScope adviceScope) { + if (adviceScope != null) { + adviceScope.end(throwable); + } + } + } } diff --git a/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchAttributesGetter.java b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchAttributesGetter.java new file mode 100644 index 000000000000..6705a8c7eb6f --- /dev/null +++ b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchAttributesGetter.java @@ -0,0 +1,42 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.lettuce.v5_0; + +import io.opentelemetry.instrumentation.api.incubator.semconv.db.DbClientAttributesGetter; +import io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues; +import javax.annotation.Nullable; + +final class LettuceBatchAttributesGetter + implements DbClientAttributesGetter { + + @Override + public String getDbSystemName(LettuceBatchRequest request) { + return DbSystemNameIncubatingValues.REDIS; + } + + @Override + @Nullable + public String getDbNamespace(LettuceBatchRequest request) { + return null; + } + + @Override + @Nullable + public String getDbQueryText(LettuceBatchRequest request) { + return request.getQueryText(); + } + + @Override + public String getDbOperationName(LettuceBatchRequest request) { + return request.getOperationName(); + } + + @Override + @Nullable + public Long getDbOperationBatchSize(LettuceBatchRequest request) { + return request.getBatchSize(); + } +} diff --git a/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchContext.java b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchContext.java new file mode 100644 index 000000000000..0b0d260bc85c --- /dev/null +++ b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchContext.java @@ -0,0 +1,148 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.lettuce.v5_0; + +import static io.opentelemetry.javaagent.bootstrap.Java8BytecodeBridge.currentContext; +import static io.opentelemetry.javaagent.instrumentation.lettuce.v5_0.LettuceSingletons.CONTEXT; +import static io.opentelemetry.javaagent.instrumentation.lettuce.v5_0.LettuceSingletons.batchInstrumenter; + +import io.lettuce.core.protocol.AsyncCommand; +import io.lettuce.core.protocol.RedisCommand; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.incubator.config.internal.DeclarativeConfigUtil; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nullable; + +public final class LettuceBatchContext { + private static final boolean CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES = + DeclarativeConfigUtil.getInstrumentationConfig(GlobalOpenTelemetry.get(), "lettuce") + .getBoolean("experimental_span_attributes/development", false); + + private static final Map states = + Collections.synchronizedMap(new WeakHashMap<>()); + + public static void setCollecting(Object commands, boolean collecting) { + if (collecting) { + states.put(commands, new BatchState()); + } else { + states.remove(commands); + } + } + + public static boolean isCollecting(Object commands) { + return states.containsKey(commands); + } + + public static boolean capture( + Object commands, + RedisCommand command, + @Nullable AsyncCommand asyncCommand) { + BatchState state = states.get(commands); + if (state == null) { + return false; + } + state.add(command, asyncCommand); + return true; + } + + @Nullable + public static BatchScope start(Object commands) { + BatchState state = states.get(commands); + if (state == null || state.commands.isEmpty()) { + return null; + } + return BatchScope.start(state.drainCommands(), state.drainAsyncCommands(), state.parentContext); + } + + private LettuceBatchContext() {} + + public static final class BatchScope { + private final Context context; + private final LettuceBatchRequest request; + private final AtomicInteger remaining; + + private BatchScope(Context context, LettuceBatchRequest request, int remaining) { + this.context = context; + this.request = request; + this.remaining = new AtomicInteger(remaining); + } + + @Nullable + private static BatchScope start( + List> commands, + List> asyncCommands, + @Nullable Context capturedParentContext) { + LettuceBatchRequest request = LettuceBatchRequest.create(commands); + Context parentContext = + capturedParentContext == null ? currentContext() : capturedParentContext; + if (!batchInstrumenter().shouldStart(parentContext, request)) { + return null; + } + Context context = batchInstrumenter().start(parentContext, request); + if (asyncCommands.isEmpty()) { + batchInstrumenter().end(context, request, null, null); + return null; + } + BatchScope scope = new BatchScope(context, request, asyncCommands.size()); + for (AsyncCommand asyncCommand : asyncCommands) { + asyncCommand.handleAsync( + (value, throwable) -> { + scope.endOne(throwable); + return null; + }); + } + return scope; + } + + public void endOne(@Nullable Throwable throwable) { + if (throwable instanceof CancellationException) { + if (CAPTURE_EXPERIMENTAL_SPAN_ATTRIBUTES) { + Span.fromContext(context).setAttribute("lettuce.command.cancelled", true); + } + throwable = null; + } + if (remaining.getAndDecrement() == 1) { + batchInstrumenter().end(context, request, null, throwable); + } + } + } + + private static final class BatchState { + private final List> commands = new ArrayList<>(); + private final List> asyncCommands = new ArrayList<>(); + @Nullable private Context parentContext; + + private void add(RedisCommand command, @Nullable AsyncCommand asyncCommand) { + commands.add(command); + if (parentContext == null && asyncCommand != null) { + parentContext = CONTEXT.get(asyncCommand); + } + if (asyncCommand != null && LettuceInstrumentationUtil.expectsResponse(command)) { + asyncCommands.add(asyncCommand); + } + } + + private List> drainCommands() { + List> drainedCommands = new ArrayList<>(commands); + commands.clear(); + return drainedCommands; + } + + private List> drainAsyncCommands() { + List> drainedAsyncCommands = new ArrayList<>(asyncCommands); + asyncCommands.clear(); + return drainedAsyncCommands; + } + } +} diff --git a/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchRequest.java b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchRequest.java new file mode 100644 index 000000000000..01f7b646daec --- /dev/null +++ b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceBatchRequest.java @@ -0,0 +1,94 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.lettuce.v5_0; + +import static java.util.Collections.emptyList; + +import io.lettuce.core.protocol.RedisCommand; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.instrumentation.api.incubator.config.internal.DbConfig; +import io.opentelemetry.instrumentation.api.incubator.semconv.db.RedisCommandSanitizer; +import io.opentelemetry.instrumentation.lettuce.common.LettuceArgSplitter; +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; +import javax.annotation.Nullable; + +final class LettuceBatchRequest { + private static final RedisCommandSanitizer sanitizer = + RedisCommandSanitizer.create( + DbConfig.isQuerySanitizationEnabled(GlobalOpenTelemetry.get(), "lettuce")); + + private final String operationName; + @Nullable private final String queryText; + @Nullable private final Long batchSize; + + private LettuceBatchRequest( + String operationName, @Nullable String queryText, @Nullable Long batchSize) { + this.operationName = operationName; + this.queryText = queryText; + this.batchSize = batchSize; + } + + static LettuceBatchRequest create(List> commands) { + return new LettuceBatchRequest( + operationName(commands), + queryText(commands), + commands.size() > 1 ? (long) commands.size() : null); + } + + String getOperationName() { + return operationName; + } + + @Nullable + String getQueryText() { + return queryText; + } + + @Nullable + Long getBatchSize() { + return batchSize; + } + + private static String operationName(List> commands) { + if (commands.size() == 1) { + return LettuceInstrumentationUtil.getCommandName(commands.get(0)); + } + String operationName = LettuceInstrumentationUtil.getCommandName(commands.get(0)); + for (int i = 1; i < commands.size(); i++) { + if (!operationName.equals(LettuceInstrumentationUtil.getCommandName(commands.get(i)))) { + return "PIPELINE"; + } + } + return "PIPELINE " + operationName; + } + + @Nullable + private static String queryText(List> commands) { + StringJoiner joiner = new StringJoiner(";"); + List queryTexts = new ArrayList<>(commands.size()); + for (RedisCommand command : commands) { + queryTexts.add(queryText(command)); + } + if (queryTexts.isEmpty()) { + return null; + } + for (String queryText : queryTexts) { + joiner.add(queryText); + } + return joiner.toString(); + } + + private static String queryText(RedisCommand command) { + String commandName = LettuceInstrumentationUtil.getCommandName(command); + List args = + command.getArgs() == null + ? emptyList() + : LettuceArgSplitter.splitArgs(command.getArgs().toCommandString()); + return sanitizer.sanitize(commandName, args); + } +} diff --git a/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceSingletons.java b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceSingletons.java index 23c7981d27f4..2a6f56bed833 100644 --- a/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceSingletons.java +++ b/instrumentation/lettuce/lettuce-5.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceSingletons.java @@ -28,6 +28,7 @@ public class LettuceSingletons { private static final String INSTRUMENTATION_NAME = "io.opentelemetry.lettuce-5.0"; private static final Instrumenter, Void> instrumenter; + private static final Instrumenter batchInstrumenter; private static final Instrumenter connectInstrumenter; public static final ContextKey COMMAND_CONTEXT_KEY = @@ -50,6 +51,17 @@ public class LettuceSingletons { instrumenter = builder.buildInstrumenter(SpanKindExtractor.alwaysClient()); + LettuceBatchAttributesGetter batchAttributesGetter = new LettuceBatchAttributesGetter(); + InstrumenterBuilder batchBuilder = + Instrumenter.builder( + GlobalOpenTelemetry.get(), + INSTRUMENTATION_NAME, + DbClientSpanNameExtractor.create(batchAttributesGetter)) + .addAttributesExtractor(DbClientAttributesExtractor.create(batchAttributesGetter)) + .addOperationMetrics(DbClientMetrics.get()); + setDbClientExceptionEventExtractor(batchBuilder); + batchInstrumenter = batchBuilder.buildInstrumenter(SpanKindExtractor.alwaysClient()); + LettuceConnectNetworkAttributesGetter connectNetworkAttributesGetter = new LettuceConnectNetworkAttributesGetter(); @@ -73,6 +85,10 @@ public class LettuceSingletons { return instrumenter; } + public static Instrumenter batchInstrumenter() { + return batchInstrumenter; + } + public static Instrumenter connectInstrumenter() { return connectInstrumenter; } diff --git a/instrumentation/lettuce/lettuce-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java b/instrumentation/lettuce/lettuce-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java index bd6d882d243b..f74f4b78947f 100644 --- a/instrumentation/lettuce/lettuce-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java +++ b/instrumentation/lettuce/lettuce-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java @@ -13,6 +13,7 @@ import static io.opentelemetry.javaagent.instrumentation.lettuce.v5_0.ExperimentalHelper.experimental; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; import static io.opentelemetry.semconv.ServerAttributes.SERVER_PORT; @@ -21,6 +22,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.REDIS; import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchException; @@ -54,10 +56,14 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Stream; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; @SuppressWarnings("deprecation") // using deprecated semconv class LettuceAsyncClientTest extends AbstractLettuceClientTest { @@ -484,6 +490,115 @@ void testCancelCommandBeforeItFinishes() { .hasParent(trace.getSpan(0)))); } + // Lettuce auto-flush batching is traced as one aggregate span from flush through completion. + @ParameterizedTest(name = "{0}") + @MethodSource("deferredFlushScenarios") + void deferredFlushCommand( + String name, AsyncCommandsScenario scenario, List expectedCommands) + throws Exception { + asyncCommands.setAutoFlushCommands(false); + cleanup.deferCleanup(() -> asyncCommands.setAutoFlushCommands(true)); + + List> futures = scenario.run(asyncCommands); + asyncCommands.flushCommands(); + for (RedisFuture future : futures) { + future.get(10, SECONDS); + } + + if (expectedCommands.isEmpty()) { + assertThat(testing.spans()).isEmpty(); + return; + } + + String operation = pipelineOperation(expectedCommands); + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName(operation) + .hasKind(SpanKind.CLIENT) + .hasAttributesSatisfyingExactly( + equalTo(maybeStable(DB_SYSTEM), REDIS), + equalTo(maybeStable(DB_STATEMENT), pipelineStatement(expectedCommands)), + equalTo(maybeStable(DB_OPERATION), operation), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() && expectedCommands.size() > 1 + ? (long) expectedCommands.size() + : null)))); + } + + private static String pipelineOperation(List commands) { + if (commands.size() == 1) { + return commands.get(0).operation; + } + String operation = commands.get(0).operation; + for (ExpectedCommand command : commands) { + if (!operation.equals(command.operation)) { + return "PIPELINE"; + } + } + return "PIPELINE " + operation; + } + + private static String pipelineStatement(List commands) { + StringBuilder statement = new StringBuilder(); + for (ExpectedCommand command : commands) { + if (statement.length() > 0) { + statement.append(';'); + } + statement.append(command.statement); + } + return statement.toString(); + } + + private static Stream deferredFlushScenarios() { + return Stream.of( + Arguments.of("empty", (AsyncCommandsScenario) commands -> emptyList(), emptyList()), + Arguments.of( + "single", + (AsyncCommandsScenario) commands -> futures(commands.set("batch1", "v1")), + expectedCommands(expectedCommand("SET", "SET batch1 ?"))), + Arguments.of( + "twoSameOperation", + (AsyncCommandsScenario) + commands -> futures(commands.set("batch1", "v1"), commands.set("batch2", "v2")), + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("SET", "SET batch2 ?"))), + Arguments.of( + "twoDifferentOperations", + (AsyncCommandsScenario) + commands -> futures(commands.set("batch1", "v1"), commands.get("batch1")), + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("GET", "GET batch1")))); + } + + private static List> futures(RedisFuture... futures) { + return asList(futures); + } + + private static List expectedCommands(ExpectedCommand... commands) { + return asList(commands); + } + + private static ExpectedCommand expectedCommand(String operation, String statement) { + return new ExpectedCommand(operation, statement); + } + + private interface AsyncCommandsScenario { + List> run(RedisAsyncCommands commands); + } + + private static class ExpectedCommand { + private final String operation; + private final String statement; + + private ExpectedCommand(String operation, String statement) { + this.operation = operation; + this.statement = statement; + } + } + @Test void testDebugSegfaultCommandWithNoArgumentShouldProduceSpan() { // Test Causes redis to crash therefore it needs its own container diff --git a/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java new file mode 100644 index 000000000000..c9e6622659c9 --- /dev/null +++ b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java @@ -0,0 +1,80 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.lettuce.v5_1; + +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +import io.lettuce.core.protocol.RedisCommand; +import io.opentelemetry.instrumentation.lettuce.v5_1.LettuceTelemetry; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import javax.annotation.Nullable; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +class LettuceAsyncCommandsInstrumentation implements TypeInstrumentation { + + @Override + public ElementMatcher typeMatcher() { + return named("io.lettuce.core.AbstractRedisAsyncCommands"); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + named("dispatch").and(takesArgument(0, named("io.lettuce.core.protocol.RedisCommand"))), + getClass().getName() + "$DispatchAdvice"); + transformer.applyAdviceToMethod( + named("setAutoFlushCommands").and(takesArguments(1)), + getClass().getName() + "$SetAutoFlushAdvice"); + transformer.applyAdviceToMethod( + named("flushCommands").and(takesArguments(0)), getClass().getName() + "$FlushAdvice"); + } + + @SuppressWarnings("unused") + public static class DispatchAdvice { + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void onExit( + @Advice.This Object commands, + @Advice.Argument(0) RedisCommand command, + @Advice.Thrown @Nullable Throwable throwable) { + if (throwable == null) { + LettuceTelemetry.capture(commands, command); + } + } + } + + @SuppressWarnings("unused") + public static class SetAutoFlushAdvice { + + @Advice.OnMethodExit(suppress = Throwable.class, inline = false) + public static void onExit(@Advice.This Object commands, @Advice.Argument(0) boolean autoFlush) { + LettuceTelemetry.setAutoFlushCommands(commands, autoFlush); + } + } + + @SuppressWarnings("unused") + public static class FlushAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + @Nullable + public static Object onEnter(@Advice.This Object commands) { + return LettuceTelemetry.startBatch(commands); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void onExit( + @Advice.Thrown @Nullable Throwable throwable, @Advice.Enter @Nullable Object batch) { + if (batch != null) { + LettuceTelemetry.finishBatch(batch, throwable); + } + } + } +} diff --git a/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceCommandHandlerInstrumentation.java b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceCommandHandlerInstrumentation.java new file mode 100644 index 000000000000..fa3f9f3f0b8e --- /dev/null +++ b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceCommandHandlerInstrumentation.java @@ -0,0 +1,47 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.lettuce.v5_1; + +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import io.lettuce.core.protocol.RedisCommand; +import io.opentelemetry.instrumentation.lettuce.v5_1.LettuceTelemetry; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +class LettuceCommandHandlerInstrumentation implements TypeInstrumentation { + + @Override + public ElementMatcher typeMatcher() { + return named("io.lettuce.core.protocol.CommandHandler"); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + named("writeSingleCommand") + .and(takesArgument(1, named("io.lettuce.core.protocol.RedisCommand"))), + getClass().getName() + "$WriteSingleCommandAdvice"); + } + + @SuppressWarnings("unused") + public static class WriteSingleCommandAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static void onEnter(@Advice.Argument(1) RedisCommand command) { + LettuceTelemetry.startCommand(command); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void onExit() { + LettuceTelemetry.endCommand(); + } + } +} diff --git a/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceInstrumentationModule.java b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceInstrumentationModule.java index ca112c92a2cd..de43f947defa 100644 --- a/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceInstrumentationModule.java +++ b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceInstrumentationModule.java @@ -34,6 +34,10 @@ public boolean isHelperClass(String className) { @Override public List typeInstrumentations() { - return asList(new ClientResourcesInstrumentation(), new LettuceAsyncCommandInstrumentation()); + return asList( + new ClientResourcesInstrumentation(), + new LettuceAsyncCommandsInstrumentation(), + new LettuceCommandHandlerInstrumentation(), + new LettuceAsyncCommandInstrumentation()); } } diff --git a/instrumentation/lettuce/lettuce-5.1/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncClientTest.java b/instrumentation/lettuce/lettuce-5.1/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncClientTest.java index 5ff3b2ee611a..a4b9e6da348c 100644 --- a/instrumentation/lettuce/lettuce-5.1/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncClientTest.java +++ b/instrumentation/lettuce/lettuce-5.1/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncClientTest.java @@ -31,4 +31,9 @@ protected RedisClient createClient(String uri) { protected boolean connectHasSpans() { return testLatestDeps(); } + + @Override + protected boolean aggregateDeferredFlush() { + return true; + } } diff --git a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceDbAttributesGetter.java b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceDbAttributesGetter.java index 7d8c52a4cb7c..d8f2e6c3487b 100644 --- a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceDbAttributesGetter.java +++ b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceDbAttributesGetter.java @@ -39,6 +39,12 @@ public String getDbOperationName(LettuceRequest request) { return request.getCommand(); } + @Nullable + @Override + public Long getDbOperationBatchSize(LettuceRequest request) { + return request.getBatchSize(); + } + @Nullable @Override public String getServerAddress(LettuceRequest request) { diff --git a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceRequest.java b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceRequest.java index 3be82e89af20..3504283e2dc9 100644 --- a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceRequest.java +++ b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceRequest.java @@ -6,10 +6,14 @@ package io.opentelemetry.instrumentation.lettuce.v5_1; import static io.opentelemetry.instrumentation.lettuce.common.LettuceArgSplitter.splitArgs; +import static java.util.Collections.emptyList; +import io.lettuce.core.protocol.OtelCommandArgsUtil; +import io.lettuce.core.protocol.RedisCommand; import io.opentelemetry.instrumentation.api.incubator.semconv.db.RedisCommandSanitizer; import java.net.InetSocketAddress; import java.util.List; +import java.util.StringJoiner; import javax.annotation.Nullable; final class LettuceRequest { @@ -20,6 +24,8 @@ final class LettuceRequest { @Nullable private String argsString; @Nullable private InetSocketAddress address; @Nullable private Long databaseIndex; + @Nullable private Long batchSize; + private boolean pipeline; LettuceRequest(RedisCommandSanitizer sanitizer) { this.sanitizer = sanitizer; @@ -55,21 +61,63 @@ void setDatabaseIndex(long databaseIndex) { this.databaseIndex = databaseIndex; } + void setPipeline(List> commands) { + command = pipelineOperationName(commands); + argsList = null; + argsString = pipelineStatement(commands); + batchSize = commands.size() > 1 ? (long) commands.size() : null; + pipeline = true; + } + @Nullable Long getDatabaseIndex() { return databaseIndex; } + @Nullable + Long getBatchSize() { + return batchSize; + } + @Nullable String getStatement() { String cmd = command; if (cmd == null) { return null; } + if (pipeline) { + return argsString; + } List args = argsList; if (args == null) { args = splitArgs(argsString); } return sanitizer.sanitize(cmd, args); } + + private static String pipelineOperationName(List> commands) { + String operationName = commands.get(0).getType().name(); + if (commands.size() == 1) { + return operationName; + } + for (int i = 1; i < commands.size(); i++) { + if (!operationName.equals(commands.get(i).getType().name())) { + return "PIPELINE"; + } + } + return "PIPELINE " + operationName; + } + + private String pipelineStatement(List> commands) { + StringJoiner joiner = new StringJoiner(";"); + for (RedisCommand command : commands) { + String commandName = command.getType().name(); + List args = + command.getArgs() == null + ? emptyList() + : OtelCommandArgsUtil.getCommandArgs(command.getArgs()); + joiner.add(sanitizer.sanitize(commandName, args)); + } + return joiner.toString(); + } } diff --git a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceTelemetry.java b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceTelemetry.java index c06136be13d2..5e1ffbcfca61 100644 --- a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceTelemetry.java +++ b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceTelemetry.java @@ -5,10 +5,12 @@ package io.opentelemetry.instrumentation.lettuce.v5_1; +import io.lettuce.core.protocol.RedisCommand; import io.lettuce.core.tracing.Tracing; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.instrumentation.api.incubator.semconv.db.RedisCommandSanitizer; import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import javax.annotation.Nullable; /** Entrypoint for instrumenting Lettuce or clients. */ public final class LettuceTelemetry { @@ -30,6 +32,37 @@ public static LettuceTelemetryBuilder builder(OpenTelemetry openTelemetry) { return new LettuceTelemetryBuilder(openTelemetry); } + /** Used by the javaagent to track Lettuce auto-flush batches. */ + public static void setAutoFlushCommands(Object commands, boolean autoFlush) { + OpenTelemetryTracing.setAutoFlushCommands(commands, autoFlush); + } + + /** Used by the javaagent to capture commands while Lettuce auto-flush is disabled. */ + public static void capture(Object commands, RedisCommand command) { + OpenTelemetryTracing.capture(commands, command); + } + + /** Used by the javaagent to start an aggregate span for a flushed Lettuce batch. */ + @Nullable + public static Object startBatch(Object commands) { + return OpenTelemetryTracing.startBatch(commands); + } + + /** Used by the javaagent to clear the active flushed batch and end it on synchronous failure. */ + public static void finishBatch(Object batch, @Nullable Throwable throwable) { + OpenTelemetryTracing.finishBatch(batch, throwable); + } + + /** Used by the javaagent to activate batch suppression while Lettuce writes a command. */ + public static void startCommand(RedisCommand command) { + OpenTelemetryTracing.startCommand(command); + } + + /** Used by the javaagent to clear the active command write batch suppression. */ + public static void endCommand() { + OpenTelemetryTracing.endCommand(); + } + LettuceTelemetry( Instrumenter instrumenter, boolean querySanitizationEnabled, diff --git a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/OpenTelemetryTracing.java b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/OpenTelemetryTracing.java index 6ab0c0bb1f9c..e1a5360daa85 100644 --- a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/OpenTelemetryTracing.java +++ b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/OpenTelemetryTracing.java @@ -9,6 +9,7 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import io.lettuce.core.output.CommandOutput; +import io.lettuce.core.protocol.CommandWrapper; import io.lettuce.core.protocol.CompleteableCommand; import io.lettuce.core.protocol.OtelCommandArgsUtil; import io.lettuce.core.protocol.RedisCommand; @@ -25,13 +26,68 @@ import java.net.SocketAddress; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nullable; final class OpenTelemetryTracing implements Tracing { + private static final Map batchStates = + Collections.synchronizedMap(new WeakHashMap<>()); + private static final Map, BatchScope> activeBatchCommands = + Collections.synchronizedMap(new WeakHashMap<>()); + private static final ThreadLocal currentBatchScope = new ThreadLocal<>(); private final TracerProvider tracerProvider; + static void setAutoFlushCommands(Object commands, boolean autoFlush) { + if (autoFlush) { + batchStates.remove(commands); + } else { + batchStates.put(commands, new BatchState()); + } + } + + static void capture(Object commands, RedisCommand command) { + BatchState batchState = batchStates.get(commands); + if (batchState != null) { + batchState.capture(command); + } + } + + @Nullable + static Object startBatch(Object commands) { + BatchState batchState = batchStates.get(commands); + if (batchState == null) { + return null; + } + BatchScope batchScope = batchState.start(); + return batchScope; + } + + static void finishBatch(Object batch, @Nullable Throwable throwable) { + currentBatchScope.remove(); + if (throwable != null && batch instanceof BatchScope) { + ((BatchScope) batch).finish(throwable); + } + } + + static void startCommand(RedisCommand command) { + BatchScope batchScope = activeBatchCommands.get(command); + if (batchScope == null) { + batchScope = activeBatchCommands.get(CommandWrapper.unwrap(command)); + } + if (batchScope != null) { + currentBatchScope.set(batchScope); + } + } + + static void endCommand() { + currentBatchScope.remove(); + } + OpenTelemetryTracing( Instrumenter instrumenter, RedisCommandSanitizer sanitizer, @@ -150,6 +206,81 @@ private OpenTelemetrySpan nextSpan(Context parentContext) { } } + private static final class BatchState { + private final List> commands = new ArrayList<>(); + + private synchronized void capture(RedisCommand command) { + commands.add(command); + } + + @Nullable + private synchronized BatchScope start() { + if (commands.isEmpty()) { + return null; + } + + List> batchCommands = new ArrayList<>(commands); + commands.clear(); + return new BatchScope(batchCommands); + } + } + + private static final class BatchScope { + private final List> commands; + private final AtomicInteger remaining; + @Nullable private OpenTelemetrySpan aggregateSpan; + @Nullable private Throwable error; + @Nullable private String errorMessage; + + private BatchScope(List> commands) { + this.commands = commands; + this.remaining = new AtomicInteger(commands.size()); + for (RedisCommand command : commands) { + activeBatchCommands.put(command, this); + } + } + + private synchronized boolean capture(OpenTelemetrySpan span) { + if (aggregateSpan == null) { + aggregateSpan = span.createAggregateSpan(commands); + } + span.batchScope = this; + return true; + } + + private synchronized void finishOne(OpenTelemetrySpan span) { + LettuceResponse response = span.response; + if (response != null && response.getErrorMessage() != null && errorMessage == null) { + errorMessage = response.getErrorMessage(); + } + if (span.errorMessage != null && errorMessage == null) { + errorMessage = span.errorMessage; + } + if (span.error != null && error == null) { + error = span.error; + } + + if (remaining.getAndDecrement() == 1) { + finish(null); + } + } + + private synchronized void finish(@Nullable Throwable throwable) { + OpenTelemetrySpan span = aggregateSpan; + if (span == null) { + return; + } + aggregateSpan = null; + for (RedisCommand command : commands) { + activeBatchCommands.remove(command); + } + if (throwable != null) { + error = throwable; + } + span.finishWithResponse(new LettuceResponse(errorMessage, error)); + } + } + // The order that callbacks will be called in or which thread they are called from is not well // defined. We go ahead and buffer all data until we know we have a span. This implementation is // particularly safe, synchronizing all accesses. Relying on implementation details would allow @@ -158,13 +289,17 @@ private static class OpenTelemetrySpan extends Tracer.Span { private final Context parentContext; private final Instrumenter instrumenter; + private final RedisCommandSanitizer sanitizer; private final boolean encodingEventsEnabled; private final LettuceRequest request; @Nullable private List events; @Nullable private Throwable error; + @Nullable private String errorMessage; @Nullable private LettuceResponse response; @Nullable private Context context; + @Nullable private BatchScope batchScope; + private boolean aggregate; OpenTelemetrySpan( Context parentContext, @@ -173,6 +308,7 @@ private static class OpenTelemetrySpan extends Tracer.Span { boolean encodingEventsEnabled) { this.parentContext = parentContext; this.instrumenter = instrumenter; + this.sanitizer = sanitizer; this.encodingEventsEnabled = encodingEventsEnabled; this.request = new LettuceRequest(sanitizer); } @@ -204,6 +340,20 @@ public synchronized Tracer.Span start(RedisCommand command) { request.setArgsList(OtelCommandArgsUtil.getCommandArgs(command.getArgs())); } + BatchScope batchScope = aggregate ? null : currentBatchScope.get(); + if (batchScope != null && batchScope.capture(this)) { + if (command instanceof CompleteableCommand) { + CompleteableCommand completeableCommand = (CompleteableCommand) command; + completeableCommand.onComplete( + (o, throwable) -> { + CommandOutput output = command.getOutput(); + String errorMsg = output != null ? output.getError() : null; + finishWithResponse(new LettuceResponse(errorMsg, throwable)); + }); + } + return this; + } + start(); if (context == null) { @@ -227,6 +377,11 @@ public synchronized Tracer.Span start(RedisCommand command) { @Override @CanIgnoreReturnValue public synchronized Tracer.Span start() { + BatchScope batchScope = aggregate ? null : currentBatchScope.get(); + if (batchScope != null && batchScope.capture(this)) { + return this; + } + if (instrumenter.shouldStart(parentContext, request)) { context = instrumenter.start(parentContext, request); @@ -243,6 +398,23 @@ public synchronized Tracer.Span start() { return this; } + private OpenTelemetrySpan createAggregateSpan(List> commands) { + OpenTelemetrySpan span = + new OpenTelemetrySpan(parentContext, instrumenter, sanitizer, encodingEventsEnabled); + span.aggregate = true; + span.request.setPipeline(commands); + InetSocketAddress address = request.getAddress(); + if (address != null) { + span.request.setAddress(address); + } + Long databaseIndex = request.getDatabaseIndex(); + if (databaseIndex != null) { + span.request.setDatabaseIndex(databaseIndex); + } + span.start(); + return span; + } + @Override @CanIgnoreReturnValue public synchronized Tracer.Span annotate(String value) { @@ -281,6 +453,10 @@ public synchronized Tracer.Span tag(String key, String value) { } return this; } + if (key.equals("error")) { + errorMessage = value; + return this; + } // Under old semconv forward unknown tags as raw span attributes for backward compatibility; // under stable semconv these are either captured structurally (e.g. error.type) or not needed if (emitOldDatabaseSemconv() && context != null) { @@ -306,6 +482,12 @@ private synchronized void finishWithResponse(LettuceResponse resp) { @Override public synchronized void finish() { + BatchScope batchScope = this.batchScope; + if (batchScope != null) { + this.batchScope = null; + batchScope.finishOne(this); + return; + } if (context != null) { instrumenter.end(context, request, response, error); // Null out context to prevent double-ending if both the onComplete callback and Lettuce's diff --git a/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceAsyncClientTest.java b/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceAsyncClientTest.java index a9994a65e334..cf3e2284b33a 100644 --- a/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceAsyncClientTest.java +++ b/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceAsyncClientTest.java @@ -6,8 +6,10 @@ package io.opentelemetry.instrumentation.lettuce.v5_1; import static io.opentelemetry.instrumentation.api.internal.SemconvStability.emitOldDatabaseSemconv; +import static io.opentelemetry.instrumentation.api.internal.SemconvStability.emitStableDatabaseSemconv; import static io.opentelemetry.instrumentation.testing.junit.db.SemconvStabilityUtil.maybeStable; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_PEER_ADDRESS; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_PEER_PORT; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_TYPE; @@ -19,6 +21,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.REDIS; import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; @@ -35,6 +38,7 @@ import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.instrumentation.test.utils.PortUtils; import io.opentelemetry.sdk.testing.assertj.SpanDataAssert; +import io.opentelemetry.sdk.testing.assertj.TraceAssert; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.ArrayList; @@ -46,8 +50,12 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; @SuppressWarnings({"InterruptedExceptionSwallowed", "deprecation"}) // using deprecated semconv public abstract class AbstractLettuceAsyncClientTest extends AbstractLettuceClientTest { @@ -98,6 +106,10 @@ protected boolean connectHasSpans() { return false; } + protected boolean aggregateDeferredFlush() { + return false; + } + @Test void testConnectUsingGetOnConnectionFuture() throws Exception { RedisClient testConnectionClient = RedisClient.create(embeddedDbUri); @@ -379,6 +391,150 @@ NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), }); } + @ParameterizedTest(name = "{0}") + @MethodSource("deferredFlushScenarios") + void deferredFlushCommand( + String name, AsyncCommandsScenario scenario, List expectedCommands) + throws Exception { + asyncCommands.setAutoFlushCommands(false); + cleanup.deferCleanup(() -> asyncCommands.setAutoFlushCommands(true)); + + List> futures = scenario.run(asyncCommands); + asyncCommands.flushCommands(); + for (RedisFuture future : futures) { + future.get(10, SECONDS); + } + + if (expectedCommands.isEmpty()) { + assertThat(testing().spans()).isEmpty(); + return; + } + + if (aggregateDeferredFlush()) { + String operation = pipelineOperation(expectedCommands); + testing() + .waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName(spanName(operation)) + .hasKind(SpanKind.CLIENT) + .hasAttributesSatisfyingExactly( + addExtraAttributes( + equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), + equalTo(NETWORK_PEER_ADDRESS, ip), + equalTo(NETWORK_PEER_PORT, port), + equalTo(SERVER_ADDRESS, host), + equalTo(SERVER_PORT, port), + equalTo(maybeStable(DB_SYSTEM), REDIS), + equalTo( + maybeStable(DB_STATEMENT), + pipelineStatement(expectedCommands)), + equalTo(maybeStable(DB_OPERATION), operation), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() && expectedCommands.size() > 1 + ? (long) expectedCommands.size() + : null))))); + return; + } + + List> assertions = new ArrayList<>(); + for (ExpectedCommand command : expectedCommands) { + ExpectedCommand expected = command; + assertions.add( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName(spanName(expected.operation)) + .hasKind(SpanKind.CLIENT) + .hasAttributesSatisfyingExactly( + addExtraAttributes( + equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), + equalTo(NETWORK_PEER_ADDRESS, ip), + equalTo(NETWORK_PEER_PORT, port), + equalTo(SERVER_ADDRESS, host), + equalTo(SERVER_PORT, port), + equalTo(maybeStable(DB_SYSTEM), REDIS), + equalTo(maybeStable(DB_STATEMENT), expected.statement), + equalTo(maybeStable(DB_OPERATION), expected.operation), + equalTo(DB_OPERATION_BATCH_SIZE, null))) + .satisfies(AbstractLettuceClientTest::assertCommandEncodeEvents))); + } + testing().waitAndAssertTraces(assertions); + } + + private static String pipelineOperation(List commands) { + if (commands.size() == 1) { + return commands.get(0).operation; + } + String operation = commands.get(0).operation; + for (ExpectedCommand command : commands) { + if (!operation.equals(command.operation)) { + return "PIPELINE"; + } + } + return "PIPELINE " + operation; + } + + private static String pipelineStatement(List commands) { + StringBuilder statement = new StringBuilder(); + for (ExpectedCommand command : commands) { + if (statement.length() > 0) { + statement.append(';'); + } + statement.append(command.statement); + } + return statement.toString(); + } + + private static Stream deferredFlushScenarios() { + return Stream.of( + Arguments.of("empty", (AsyncCommandsScenario) commands -> emptyList(), emptyList()), + Arguments.of( + "single", + (AsyncCommandsScenario) commands -> futures(commands.set("batch1", "v1")), + expectedCommands(expectedCommand("SET", "SET batch1 ?"))), + Arguments.of( + "twoSameOperation", + (AsyncCommandsScenario) + commands -> futures(commands.set("batch1", "v1"), commands.set("batch2", "v2")), + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("SET", "SET batch2 ?"))), + Arguments.of( + "twoDifferentOperations", + (AsyncCommandsScenario) + commands -> futures(commands.set("batch1", "v1"), commands.get("batch1")), + expectedCommands( + expectedCommand("SET", "SET batch1 ?"), expectedCommand("GET", "GET batch1")))); + } + + private static List> futures(RedisFuture... futures) { + return asList(futures); + } + + private static List expectedCommands(ExpectedCommand... commands) { + return asList(commands); + } + + private static ExpectedCommand expectedCommand(String operation, String statement) { + return new ExpectedCommand(operation, statement); + } + + private interface AsyncCommandsScenario { + List> run(RedisAsyncCommands commands); + } + + private static class ExpectedCommand { + private final String operation; + private final String statement; + + private ExpectedCommand(String operation, String statement) { + this.operation = operation; + this.statement = statement; + } + } + @Test void testHashSetAndThenNestApplyToHashGetall() throws Exception { CompletableFuture> future = new CompletableFuture<>(); diff --git a/instrumentation/redisson/redisson-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/v3_0/RedisConnectionInstrumentation.java b/instrumentation/redisson/redisson-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/v3_0/RedisConnectionInstrumentation.java index feb69857d97e..5c1786455570 100644 --- a/instrumentation/redisson/redisson-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/v3_0/RedisConnectionInstrumentation.java +++ b/instrumentation/redisson/redisson-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/v3_0/RedisConnectionInstrumentation.java @@ -15,6 +15,7 @@ import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; import io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0.EndOperationListener; import io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0.PromiseWrapper; +import io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0.RedissonBatchSpanManager; import io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0.RedissonRequest; import java.net.InetSocketAddress; import javax.annotation.Nullable; @@ -38,14 +39,30 @@ public void transform(TypeTransformer transformer) { public static class SendAdvice { public static class AdviceScope { - private final RedissonRequest request; - private final Context context; - private final Scope scope; + private final RedisConnection connection; + @Nullable private final RedissonRequest request; + @Nullable private final Context context; + @Nullable private final Scope scope; + private final boolean multiSpan; + private final boolean suppressedSpan; - private AdviceScope(RedissonRequest request, Context context, Scope scope) { + private AdviceScope( + RedisConnection connection, + @Nullable RedissonRequest request, + @Nullable Context context, + @Nullable Scope scope, + boolean multiSpan, + boolean suppressedSpan) { + this.connection = connection; this.request = request; this.context = context; this.scope = scope; + this.multiSpan = multiSpan; + this.suppressedSpan = suppressedSpan; + } + + private static AdviceScope suppressed(RedisConnection connection) { + return new AdviceScope(connection, null, null, null, false, true); } @Nullable @@ -57,6 +74,9 @@ public static AdviceScope start(RedisConnection connection, Object arg) { if (promise == null) { return null; } + if (RedissonBatchSpanManager.suppressSpanOrEndMultiSpan(connection, request, promise)) { + return suppressed(connection); + } Context parentContext = currentContext(); if (!instrumenter().shouldStart(parentContext, request)) { return null; @@ -65,16 +85,26 @@ public static AdviceScope start(RedisConnection connection, Object arg) { Context context = instrumenter().start(parentContext, request); Scope scope = context.makeCurrent(); + if (request.isMultiBatch()) { + RedissonBatchSpanManager.startMultiSpan(connection, instrumenter(), context, request); + return new AdviceScope(connection, request, context, scope, true, false); + } promise.setEndOperationListener( new EndOperationListener<>(instrumenter(), context, request)); - return new AdviceScope(request, context, scope); + return new AdviceScope(connection, request, context, scope, false, false); } public void end(@Nullable Throwable throwable) { - scope.close(); + if (scope != null) { + scope.close(); + } if (throwable != null) { - instrumenter().end(context, request, null, throwable); + if (multiSpan || suppressedSpan) { + RedissonBatchSpanManager.endMultiSpan(connection, throwable); + } else if (context != null && request != null) { + instrumenter().end(context, request, null, throwable); + } } // span ended in EndOperationListener } diff --git a/instrumentation/redisson/redisson-3.17/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/v3_17/RedisConnectionInstrumentation.java b/instrumentation/redisson/redisson-3.17/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/v3_17/RedisConnectionInstrumentation.java index 9551e9ae8d98..9fbec4a8d69e 100644 --- a/instrumentation/redisson/redisson-3.17/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/v3_17/RedisConnectionInstrumentation.java +++ b/instrumentation/redisson/redisson-3.17/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/v3_17/RedisConnectionInstrumentation.java @@ -15,6 +15,7 @@ import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; import io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0.EndOperationListener; import io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0.PromiseWrapper; +import io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0.RedissonBatchSpanManager; import io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0.RedissonRequest; import java.net.InetSocketAddress; import javax.annotation.Nullable; @@ -38,14 +39,30 @@ public void transform(TypeTransformer transformer) { public static class SendAdvice { public static class AdviceScope { - private final RedissonRequest request; - private final Context context; - private final Scope scope; + private final RedisConnection connection; + @Nullable private final RedissonRequest request; + @Nullable private final Context context; + @Nullable private final Scope scope; + private final boolean multiSpan; + private final boolean suppressedSpan; - private AdviceScope(RedissonRequest request, Context context, Scope scope) { + private AdviceScope( + RedisConnection connection, + @Nullable RedissonRequest request, + @Nullable Context context, + @Nullable Scope scope, + boolean multiSpan, + boolean suppressedSpan) { + this.connection = connection; this.request = request; this.context = context; this.scope = scope; + this.multiSpan = multiSpan; + this.suppressedSpan = suppressedSpan; + } + + private static AdviceScope suppressed(RedisConnection connection) { + return new AdviceScope(connection, null, null, null, false, true); } @Nullable @@ -58,6 +75,9 @@ public static AdviceScope start(RedisConnection connection, Object arg) { if (promise == null) { return null; } + if (RedissonBatchSpanManager.suppressSpanOrEndMultiSpan(connection, request, promise)) { + return suppressed(connection); + } if (!instrumenter().shouldStart(parentContext, request)) { return null; } @@ -65,15 +85,25 @@ public static AdviceScope start(RedisConnection connection, Object arg) { Context context = instrumenter().start(parentContext, request); Scope scope = context.makeCurrent(); + if (request.isMultiBatch()) { + RedissonBatchSpanManager.startMultiSpan(connection, instrumenter(), context, request); + return new AdviceScope(connection, request, context, scope, true, false); + } promise.setEndOperationListener( new EndOperationListener<>(instrumenter(), context, request)); - return new AdviceScope(request, context, scope); + return new AdviceScope(connection, request, context, scope, false, false); } public void end(@Nullable Throwable throwable) { - scope.close(); + if (scope != null) { + scope.close(); + } if (throwable != null) { - instrumenter().end(context, request, null, throwable); + if (multiSpan || suppressedSpan) { + RedissonBatchSpanManager.endMultiSpan(connection, throwable); + } else if (context != null && request != null) { + instrumenter().end(context, request, null, throwable); + } } // span ended in EndOperationListener } diff --git a/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonBatchSpanManager.java b/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonBatchSpanManager.java new file mode 100644 index 000000000000..fba4c3bbf464 --- /dev/null +++ b/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonBatchSpanManager.java @@ -0,0 +1,81 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0; + +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; +import javax.annotation.Nullable; + +public final class RedissonBatchSpanManager { + private static final Map activeMultiSpans = + Collections.synchronizedMap(new WeakHashMap<>()); + + public static void startMultiSpan( + Object connection, + Instrumenter instrumenter, + Context context, + RedissonRequest request) { + activeMultiSpans.put(connection, new ActiveSpan(instrumenter, context, request)); + } + + public static boolean suppressSpanOrEndMultiSpan( + Object connection, RedissonRequest request, PromiseWrapper promise) { + ActiveSpan activeSpan = activeMultiSpans.get(connection); + if (activeSpan == null) { + return false; + } + + setEndOperationListener(connection, promise, activeSpan, request.isExecCommand()); + return true; + } + + public static void endMultiSpan(Object connection, @Nullable Throwable error) { + ActiveSpan activeSpan = activeMultiSpans.remove(connection); + if (activeSpan != null) { + activeSpan.end(error); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) // promise value type is irrelevant to span ending + private static void setEndOperationListener( + Object connection, PromiseWrapper promise, ActiveSpan activeSpan, boolean endOnSuccess) { + ((PromiseWrapper) promise) + .setEndOperationListener( + new EndOperationListener( + activeSpan.instrumenter, activeSpan.context, activeSpan.request) { + @Override + public void accept(@Nullable Object unused, @Nullable Throwable error) { + if (endOnSuccess || error != null) { + endMultiSpan(connection, error); + } + } + }); + } + + private RedissonBatchSpanManager() {} + + private static final class ActiveSpan { + private final Instrumenter instrumenter; + private final Context context; + private final RedissonRequest request; + + private ActiveSpan( + Instrumenter instrumenter, + Context context, + RedissonRequest request) { + this.instrumenter = instrumenter; + this.context = context; + this.request = request; + } + + private void end(@Nullable Throwable error) { + instrumenter.end(context, request, null, error); + } + } +} diff --git a/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonRequest.java b/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonRequest.java index bad2c1915961..c8f66f37f17c 100644 --- a/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonRequest.java +++ b/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonRequest.java @@ -99,6 +99,30 @@ public String getOperationName() { return null; } + public boolean isMultiBatch() { + Object command = getCommand(); + if (!(command instanceof CommandsData)) { + return false; + } + List> commands = ((CommandsData) command).getCommands(); + return !commands.isEmpty() && commands.get(0).getCommand().getName().equals(MULTI); + } + + public boolean isExecCommand() { + Object command = getCommand(); + if (command instanceof CommandData) { + return ((CommandData) command).getCommand().getName().equals("EXEC"); + } + if (command instanceof CommandsData) { + for (CommandData singleCommand : ((CommandsData) command).getCommands()) { + if (singleCommand.getCommand().getName().equals("EXEC")) { + return true; + } + } + } + return false; + } + @Nullable public Long getOperationBatchSize() { Object command = getCommand(); diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index 83425c2f3063..12af9b9593aa 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -399,8 +399,7 @@ void atomicBatchCommand() { batch.getBucket("batch2").setAsync("v2"); batch.execute(); }); - testing.waitAndAssertSortedTraces( - orderByRootSpanName("MULTI SET", "DB Query", "SET", "EXEC"), + testing.waitAndAssertTraces( trace -> trace.hasSpansSatisfyingExactly( span -> span.hasName("parent").hasNoParent().hasKind(INTERNAL), @@ -416,31 +415,8 @@ void atomicBatchCommand() { DB_OPERATION_NAME, emitStableDatabaseSemconv() ? "MULTI SET" : null), // db.operation.batch.size is not emitted because MULTI transaction - // telemetry is split across wrapper and command spans, so this span - // does not represent the full logical batch. + // wrapper only sees MULTI plus the first queued command. equalTo(maybeStable(DB_STATEMENT), "MULTI;SET batch1 ?")) - .hasParent(trace.getSpan(0)), - span -> - span.hasName("SET") - .hasKind(CLIENT) - .hasAttributesSatisfyingExactly( - equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), - equalTo(NETWORK_PEER_ADDRESS, ip), - equalTo(NETWORK_PEER_PORT, port), - equalTo(maybeStable(DB_SYSTEM), REDIS), - equalTo(maybeStable(DB_STATEMENT), "SET batch2 ?"), - equalTo(maybeStable(DB_OPERATION), "SET")) - .hasParent(trace.getSpan(0)), - span -> - span.hasName("EXEC") - .hasKind(CLIENT) - .hasAttributesSatisfyingExactly( - equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), - equalTo(NETWORK_PEER_ADDRESS, ip), - equalTo(NETWORK_PEER_PORT, port), - equalTo(maybeStable(DB_SYSTEM), REDIS), - equalTo(maybeStable(DB_STATEMENT), "EXEC"), - equalTo(maybeStable(DB_OPERATION), "EXEC")) .hasParent(trace.getSpan(0)))); } From fe3972b16a3aa5604821a9aa3815cd5980bd5164 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Wed, 17 Jun 2026 15:48:48 -0700 Subject: [PATCH 24/31] Add batch telemetry for HBase and Redis clients --- .../AbstractRpcClientInstrumentation.java | 18 +- .../client/v2_0/HbaseAttributesGetter.java | 6 + .../hbase/client/v2_0/HbaseRequest.java | 8 +- .../hbase/testing/AbstractHbaseTest.java | 218 +++++++++++++++--- .../v5_1/AbstractLettuceSyncClientTest.java | 6 +- .../rediscala/v1_8/OnCompleteHandler.java | 5 +- .../v1_8/RediscalaAttributesGetter.java | 21 +- .../v1_8/RediscalaInstrumentationModule.java | 4 +- .../rediscala/v1_8/RediscalaRequest.java | 65 ++++++ .../rediscala/v1_8/RediscalaSingletons.java | 9 +- .../v1_8/RequestInstrumentation.java | 33 +-- .../v1_8/TransactionInstrumentation.java | 95 ++++++++ .../rediscala/v1_8/RediscalaClientTest.scala | 122 ++++++++++ .../AbstractRedissonAsyncClientTest.java | 25 +- .../redisson/AbstractRedissonClientTest.java | 1 + ...isStandaloneConnectionInstrumentation.java | 86 +++++++ .../VertxRedisClientAttributesGetter.java | 17 +- .../v4_0/VertxRedisClientRequest.java | 63 +++++ .../v4_0/VertxRedisClientTest.java | 121 ++++++++++ 19 files changed, 820 insertions(+), 103 deletions(-) create mode 100644 instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaRequest.java create mode 100644 instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/TransactionInstrumentation.java diff --git a/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/AbstractRpcClientInstrumentation.java b/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/AbstractRpcClientInstrumentation.java index 69fd975c7420..1eb18abe7086 100644 --- a/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/AbstractRpcClientInstrumentation.java +++ b/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/AbstractRpcClientInstrumentation.java @@ -24,6 +24,7 @@ import net.bytebuddy.matcher.ElementMatcher; import org.apache.hadoop.hbase.net.Address; import org.apache.hadoop.hbase.security.User; +import org.apache.hadoop.hbase.shaded.protobuf.generated.ClientProtos; import org.apache.hbase.thirdparty.com.google.protobuf.Descriptors; class AbstractRpcClientInstrumentation implements TypeInstrumentation { @@ -51,6 +52,7 @@ public static class CallMethodAdvice { @Advice.OnMethodEnter(suppress = Throwable.class) public static RequestAndContext onEnter( @Advice.Argument(0) Descriptors.MethodDescriptor md, + @Advice.Argument(2) Object param, @Advice.Argument(4) User ticket, @Advice.Argument(5) Object addr) { String hostname = null; @@ -65,7 +67,8 @@ public static RequestAndContext onEnter( hostname = address.getHostString(); } HbaseRequest request = - HbaseRequest.create(md.getName(), getTableName(), ticket.getName(), hostname, port); + HbaseRequest.create( + md.getName(), getTableName(), ticket.getName(), hostname, port, getBatchSize(param)); Context parentContext = Java8BytecodeBridge.currentContext(); if (!instrumenter().shouldStart(parentContext, request)) { return null; @@ -77,6 +80,19 @@ public static RequestAndContext onEnter( return requestAndContext; } + @Nullable + public static Long getBatchSize(Object param) { + if (!(param instanceof ClientProtos.MultiRequest)) { + return null; + } + long batchSize = 0; + for (ClientProtos.RegionAction regionAction : + ((ClientProtos.MultiRequest) param).getRegionActionList()) { + batchSize += regionAction.getActionCount(); + } + return batchSize > 1 ? batchSize : null; + } + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) public static void onExit( @Advice.Thrown @Nullable Throwable throwable, diff --git a/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/HbaseAttributesGetter.java b/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/HbaseAttributesGetter.java index 3454e9fb402a..932e43087693 100644 --- a/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/HbaseAttributesGetter.java +++ b/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/HbaseAttributesGetter.java @@ -62,6 +62,12 @@ public String getDbOperationName(HbaseRequest hbaseRequest) { return hbaseRequest.getOperation(); } + @Nullable + @Override + public Long getDbOperationBatchSize(HbaseRequest hbaseRequest) { + return hbaseRequest.getBatchSize(); + } + @Nullable @Override public InetSocketAddress getNetworkPeerInetSocketAddress( diff --git a/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/HbaseRequest.java b/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/HbaseRequest.java index 3d4970de785a..33fae74b53ca 100644 --- a/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/HbaseRequest.java +++ b/instrumentation/hbase-client-2.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/client/v2_0/HbaseRequest.java @@ -17,8 +17,9 @@ public static HbaseRequest create( @Nullable TableName tableName, @Nullable String user, @Nullable String host, - @Nullable Integer port) { - return new AutoValue_HbaseRequest(operation, tableName, user, host, port); + @Nullable Integer port, + @Nullable Long batchSize) { + return new AutoValue_HbaseRequest(operation, tableName, user, host, port, batchSize); } @Nullable @@ -35,4 +36,7 @@ public static HbaseRequest create( @Nullable public abstract Integer getPort(); + + @Nullable + public abstract Long getBatchSize(); } diff --git a/instrumentation/hbase-client-2.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/testing/AbstractHbaseTest.java b/instrumentation/hbase-client-2.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/testing/AbstractHbaseTest.java index 942c9f0f6510..481e8f2379e8 100644 --- a/instrumentation/hbase-client-2.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/testing/AbstractHbaseTest.java +++ b/instrumentation/hbase-client-2.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/hbase/testing/AbstractHbaseTest.java @@ -12,6 +12,7 @@ import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; import static io.opentelemetry.semconv.DbAttributes.DB_COLLECTION_NAME; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.DbAttributes.DB_SYSTEM_NAME; import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_MESSAGE; @@ -23,6 +24,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_OPERATION; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_USER; +import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -41,6 +43,7 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import java.util.stream.Stream; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hbase.CompareOperator; import org.apache.hadoop.hbase.HBaseConfiguration; @@ -59,6 +62,7 @@ import org.apache.hadoop.hbase.client.Put; import org.apache.hadoop.hbase.client.Result; import org.apache.hadoop.hbase.client.ResultScanner; +import org.apache.hadoop.hbase.client.Row; import org.apache.hadoop.hbase.client.RowMutations; import org.apache.hadoop.hbase.client.Scan; import org.apache.hadoop.hbase.client.Table; @@ -71,6 +75,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.containers.wait.strategy.WaitAllStrategy; @@ -98,7 +104,6 @@ public abstract class AbstractHbaseTest { private static final int GET_TIMEOUT_OPERATION_TIMEOUT_MILLIS = 1000; private static final int GET_TIMEOUT_RPC_TIMEOUT_MILLIS = 200; private static final String ROW_1 = "row1"; - private static final String ROW_2 = "row2"; private static final String ROW_3 = "row3"; private static final String ROW_4 = "row4"; private static final String ROW_5 = "row5"; @@ -354,47 +359,100 @@ void testScan() throws IOException { testing().waitAndAssertTraces(traceAssertConsumer(TABLE_NAME, SCAN, REGION_SERVER_PORT, true)); } - @Test - void testBatchGet() throws IOException { - Result[] results; + // describes the batch cases: empty, single action, two actions with the same type, and two + // actions with different types. HBase reports all non-empty Table.batch(...) calls as Multi; + // db.operation.batch.size is emitted only when there are multiple actions under stable database + // semconv. + @ParameterizedTest + @MethodSource("batchScenarios") + void testBatch(BatchScenario scenario) throws IOException, InterruptedException { + Object[] results = new Object[scenario.actions.size()]; try (Table table = connection.getTable(TABLE_NAME)) { - List getList = new ArrayList<>(); - getList.add(new Get(Bytes.toBytes(ROW_1))); - getList.add(new Get(Bytes.toBytes(ROW_2))); - getList.add(new Get(Bytes.toBytes(ROW_5))); - results = table.get(getList); - } - assertThat(results).hasSize(3); - { - assertThat(Bytes.toString(results[0].getRow())).isEqualTo(ROW_1); - assertThat(value(results[0], "col1")).isEqualTo("col1_val_1"); - assertThat(value(results[0], "col2")).isEqualTo("col2_val_1"); + table.batch(scenario.actions, results); } - { - assertThat(Bytes.toString(results[1].getRow())).isNull(); - assertThat(value(results[1], "col1")).isNull(); - assertThat(value(results[1], "col2")).isNull(); - } - { - assertThat(Bytes.toString(results[2].getRow())).isNull(); - assertThat(value(results[2], "col1")).isNull(); - assertThat(value(results[2], "col2")).isNull(); + scenario.resultAssertions.accept(results); + + if (scenario.empty) { + assertThat(testing().spans()).isEmpty(); + return; } - testing().waitAndAssertTraces(traceAssertConsumer(TABLE_NAME, MULTI, REGION_SERVER_PORT, true)); + + testing() + .waitAndAssertTraces( + scenario.batchSize != null + ? traceAssertConsumer( + TABLE_NAME, + scenario.operation, + REGION_SERVER_PORT, + true, + scenario.batchSize.intValue()) + : traceAssertConsumer(TABLE_NAME, scenario.operation, REGION_SERVER_PORT, true)); } - @Test - void testBatchPut() throws IOException { - try (Table table = connection.getTable(TABLE_NAME)) { - List putList = new ArrayList<>(); - for (int i = 2; i < 5; i++) { - Put put = new Put(Bytes.toBytes("batch-put-row" + i)); - put.addColumn(COLUMN_FAMILY, Bytes.toBytes("col1"), Bytes.toBytes("col1_val_" + i)); - putList.add(put); - } - table.put(putList); - } - testing().waitAndAssertTraces(traceAssertConsumer(TABLE_NAME, MULTI, REGION_SERVER_PORT, true)); + private static Stream batchScenarios() { + return Stream.of( + // an empty batch produces no span + BatchScenario.builder("empty").empty().build(), + // a single-action batch is sent as Multi but is not a batch for db.operation.batch.size + BatchScenario.builder("single") + .actions(get(ROW_1)) + .results( + results -> { + assertThat(results).hasSize(1); + assertExistingRow(result(results[0]), ROW_1, "col1_val_1", "col2_val_1"); + }) + .build(), + BatchScenario.builder("twoSameOperation") + .actions(get(ROW_1), get(ROW_5)) + .batchSize(2) + .results( + results -> { + assertThat(results).hasSize(2); + assertExistingRow(result(results[0]), ROW_1, "col1_val_1", "col2_val_1"); + assertMissingRow(result(results[1])); + }) + .build(), + BatchScenario.builder("twoDifferentOperations") + .actions(put("batch-matrix-put-row"), get(ROW_1)) + .batchSize(2) + .results( + results -> { + assertThat(results).hasSize(2); + assertExistingRow(result(results[1]), ROW_1, "col1_val_1", "col2_val_1"); + }) + .build()); + } + + private static List batchActions(Row... rows) { + return asList(rows); + } + + private static Get get(String rowKey) { + return new Get(Bytes.toBytes(rowKey)); + } + + private static Put put(String rowKey) { + Put put = new Put(Bytes.toBytes(rowKey)); + put.addColumn(COLUMN_FAMILY, Bytes.toBytes("col1"), Bytes.toBytes("col1_val")); + return put; + } + + private static Result result(Object result) { + assertThat(result).isInstanceOf(Result.class); + return (Result) result; + } + + private static void assertExistingRow( + Result result, String rowKey, String col1Value, String col2Value) { + assertThat(Bytes.toString(result.getRow())).isEqualTo(rowKey); + assertThat(value(result, "col1")).isEqualTo(col1Value); + assertThat(value(result, "col2")).isEqualTo(col2Value); + } + + private static void assertMissingRow(Result result) { + assertThat(Bytes.toString(result.getRow())).isNull(); + assertThat(value(result, "col1")).isNull(); + assertThat(value(result, "col2")).isNull(); } @Test @@ -487,7 +545,7 @@ void testCheckAndMutateSuccess() throws IOException { } testing() .waitAndAssertTraces( - traceAssertConsumer(TABLE_NAME, MULTI, REGION_SERVER_PORT, true), + traceAssertConsumer(TABLE_NAME, MULTI, REGION_SERVER_PORT, true, 2), traceAssertConsumer(TABLE_NAME, GET, REGION_SERVER_PORT, true)); } @@ -526,6 +584,21 @@ void hasDurationMetric() throws IOException { protected Consumer traceAssertConsumer( TableName table, String operation, int port, boolean hasTable) { + return traceAssertConsumer(table, operation, port, hasTable, false, 0); + } + + protected Consumer traceAssertConsumer( + TableName table, String operation, int port, boolean hasTable, int batchSize) { + return traceAssertConsumer(table, operation, port, hasTable, true, batchSize); + } + + private Consumer traceAssertConsumer( + TableName table, + String operation, + int port, + boolean hasTable, + boolean hasBatchSize, + int batchSize) { String spanName; if (hasTable) { spanName = operation + " " + table.getNameAsString(); @@ -544,6 +617,11 @@ protected Consumer traceAssertConsumer( equalTo(maybeStable(DB_OPERATION), operation), equalTo(maybeStable(DB_NAME), dbNamespace(table, hasTable)), equalTo(DB_COLLECTION_NAME, dbCollectionName(table, hasTable)), + equalTo( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() && hasBatchSize + ? Long.valueOf(batchSize) + : null), equalTo(SERVER_ADDRESS, hostname), equalTo(SERVER_PORT, port), satisfies( @@ -569,4 +647,68 @@ private static String dbCollectionName(TableName table, boolean hasTable) { } return null; } + + private static final class BatchScenario { + final String name; + final List actions; + final String operation; + final Long batchSize; + final boolean empty; + final Consumer resultAssertions; + + BatchScenario(Builder builder) { + this.name = builder.name; + this.actions = builder.actions; + this.operation = MULTI; + this.batchSize = builder.batchSize; + this.empty = builder.empty; + this.resultAssertions = builder.resultAssertions; + } + + static Builder builder(String name) { + return new Builder(name); + } + + @Override + public String toString() { + // used as the parameterized test display name + return name; + } + + static final class Builder { + private final String name; + private List actions = batchActions(); + private Long batchSize; + private boolean empty; + private Consumer resultAssertions = results -> assertThat(results).isEmpty(); + + Builder(String name) { + this.name = name; + } + + Builder actions(Row... actions) { + this.actions = batchActions(actions); + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + Builder empty() { + this.empty = true; + return this; + } + + Builder results(Consumer resultAssertions) { + this.resultAssertions = resultAssertions; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } diff --git a/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceSyncClientTest.java b/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceSyncClientTest.java index 7b45f866a1b7..ab6542b0a8ba 100644 --- a/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceSyncClientTest.java +++ b/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceSyncClientTest.java @@ -542,11 +542,7 @@ void testShutdownCommandProducesNoSpan() { .hasKind(SpanKind.CLIENT) .hasAttributesSatisfyingExactly( addExtraAttributes( - equalTo( - stringKey("error"), - testLatestDeps() || emitStableDatabaseSemconv() - ? null - : "Connection disconnected"), + equalTo(stringKey("error"), null), equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), equalTo(NETWORK_PEER_ADDRESS, ip), equalTo(NETWORK_PEER_PORT, containerConnection.port), diff --git a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/OnCompleteHandler.java b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/OnCompleteHandler.java index 2b857c487452..99d8b241810c 100644 --- a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/OnCompleteHandler.java +++ b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/OnCompleteHandler.java @@ -9,15 +9,14 @@ import io.opentelemetry.context.Context; import javax.annotation.Nullable; -import redis.RedisCommand; import scala.runtime.AbstractFunction1; import scala.util.Try; class OnCompleteHandler extends AbstractFunction1, Void> { private final Context context; - private final RedisCommand request; + private final RediscalaRequest request; - OnCompleteHandler(Context context, RedisCommand request) { + OnCompleteHandler(Context context, RediscalaRequest request) { this.context = context; this.request = request; } diff --git a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaAttributesGetter.java b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaAttributesGetter.java index c32e5b939285..ca0d03e214c1 100644 --- a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaAttributesGetter.java +++ b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaAttributesGetter.java @@ -7,32 +7,35 @@ import io.opentelemetry.instrumentation.api.incubator.semconv.db.DbClientAttributesGetter; import io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues; -import java.util.Locale; import javax.annotation.Nullable; -import redis.RedisCommand; -final class RediscalaAttributesGetter - implements DbClientAttributesGetter, Void> { +final class RediscalaAttributesGetter implements DbClientAttributesGetter { @Override - public String getDbSystemName(RedisCommand redisCommand) { + public String getDbSystemName(RediscalaRequest request) { return DbSystemNameIncubatingValues.REDIS; } @Override @Nullable - public String getDbNamespace(RedisCommand redisCommand) { + public String getDbNamespace(RediscalaRequest request) { return null; } @Override @Nullable - public String getDbQueryText(RedisCommand redisCommand) { + public String getDbQueryText(RediscalaRequest request) { return null; } @Override - public String getDbOperationName(RedisCommand redisCommand) { - return redisCommand.getClass().getSimpleName().toUpperCase(Locale.ROOT); + public String getDbOperationName(RediscalaRequest request) { + return request.getOperationName(); + } + + @Override + @Nullable + public Long getDbOperationBatchSize(RediscalaRequest request) { + return request.getBatchSize(); } } diff --git a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaInstrumentationModule.java b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaInstrumentationModule.java index 5635d63bf1f7..7b3e82b8c401 100644 --- a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaInstrumentationModule.java +++ b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaInstrumentationModule.java @@ -5,7 +5,7 @@ package io.opentelemetry.javaagent.instrumentation.rediscala.v1_8; -import static java.util.Collections.singletonList; +import static java.util.Arrays.asList; import com.google.auto.service.AutoService; import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule; @@ -21,6 +21,6 @@ public RediscalaInstrumentationModule() { @Override public List typeInstrumentations() { - return singletonList(new RequestInstrumentation()); + return asList(new RequestInstrumentation(), new TransactionInstrumentation()); } } diff --git a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaRequest.java b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaRequest.java new file mode 100644 index 000000000000..755596cb5ae2 --- /dev/null +++ b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaRequest.java @@ -0,0 +1,65 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.rediscala.v1_8; + +import java.util.Locale; +import javax.annotation.Nullable; +import redis.Operation; +import redis.RedisCommand; +import scala.collection.Iterator; +import scala.collection.immutable.Queue; + +class RediscalaRequest { + private final String operationName; + @Nullable private final Long batchSize; + + static RediscalaRequest create(RedisCommand command) { + return new RediscalaRequest(operationName(command), null); + } + + static RediscalaRequest createTransaction(Queue> operations) { + return new RediscalaRequest(transactionOperationName(operations), batchSize(operations)); + } + + private RediscalaRequest(String operationName, @Nullable Long batchSize) { + this.operationName = operationName; + this.batchSize = batchSize; + } + + String getOperationName() { + return operationName; + } + + @Nullable + Long getBatchSize() { + return batchSize; + } + + private static String transactionOperationName(Queue> operations) { + if (operations.isEmpty()) { + return "MULTI"; + } + + Iterator> iterator = operations.iterator(); + String operationName = operationName(iterator.next().redisCommand()); + while (iterator.hasNext()) { + if (!operationName.equals(operationName(iterator.next().redisCommand()))) { + return "MULTI"; + } + } + return "MULTI " + operationName; + } + + @Nullable + private static Long batchSize(Queue> operations) { + int size = operations.size(); + return size > 1 ? (long) size : null; + } + + private static String operationName(RedisCommand command) { + return command.getClass().getSimpleName().toUpperCase(Locale.ROOT); + } +} diff --git a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaSingletons.java b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaSingletons.java index 67c5ec815e21..c88570b8ec12 100644 --- a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaSingletons.java +++ b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaSingletons.java @@ -14,19 +14,18 @@ import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; import io.opentelemetry.instrumentation.api.instrumenter.InstrumenterBuilder; import io.opentelemetry.instrumentation.api.instrumenter.SpanKindExtractor; -import redis.RedisCommand; public class RediscalaSingletons { private static final String INSTRUMENTATION_NAME = "io.opentelemetry.rediscala-1.8"; - private static final Instrumenter, Void> instrumenter; + private static final Instrumenter instrumenter; static { RediscalaAttributesGetter dbAttributesGetter = new RediscalaAttributesGetter(); - InstrumenterBuilder, Void> builder = - Instrumenter., Void>builder( + InstrumenterBuilder builder = + Instrumenter.builder( GlobalOpenTelemetry.get(), INSTRUMENTATION_NAME, DbClientSpanNameExtractor.create(dbAttributesGetter)) @@ -37,7 +36,7 @@ public class RediscalaSingletons { instrumenter = builder.buildInstrumenter(SpanKindExtractor.alwaysClient()); } - public static Instrumenter, Void> instrumenter() { + public static Instrumenter instrumenter() { return instrumenter; } diff --git a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RequestInstrumentation.java b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RequestInstrumentation.java index 19d9c51582c5..ec8d45a70540 100644 --- a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RequestInstrumentation.java +++ b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RequestInstrumentation.java @@ -62,28 +62,32 @@ public static class SendAdvice { public static class AdviceScope { private final Context context; private final Scope scope; + private final RediscalaRequest request; - private AdviceScope(Context context, Scope scope) { + private AdviceScope(Context context, Scope scope, RediscalaRequest request) { this.context = context; this.scope = scope; + this.request = request; } @Nullable - public static AdviceScope start(RedisCommand cmd) { + public static AdviceScope start(Object action, RedisCommand cmd) { + if (action instanceof BufferedRequest) { + return null; + } + + RediscalaRequest request = RediscalaRequest.create(cmd); Context parentContext = Context.current(); - if (!instrumenter().shouldStart(parentContext, cmd)) { + if (!instrumenter().shouldStart(parentContext, request)) { return null; } - Context context = instrumenter().start(parentContext, cmd); - return new AdviceScope(context, context.makeCurrent()); + Context context = instrumenter().start(parentContext, request); + return new AdviceScope(context, context.makeCurrent(), request); } public void end( - Object action, - RedisCommand cmd, - @Nullable Future responseFuture, - @Nullable Throwable throwable) { + Object action, @Nullable Future responseFuture, @Nullable Throwable throwable) { scope.close(); ExecutionContext ctx = null; @@ -98,17 +102,18 @@ public void end( } if (throwable != null || responseFuture == null) { - instrumenter().end(context, cmd, null, throwable); + instrumenter().end(context, request, null, throwable); } else { - responseFuture.onComplete(new OnCompleteHandler(context, cmd), ctx); + responseFuture.onComplete(new OnCompleteHandler(context, request), ctx); } } } @Nullable @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) - public static AdviceScope onEnter(@Advice.Argument(0) RedisCommand cmd) { - return AdviceScope.start(cmd); + public static AdviceScope onEnter( + @Advice.This Object action, @Advice.Argument(0) RedisCommand cmd) { + return AdviceScope.start(action, cmd); } @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) @@ -119,7 +124,7 @@ public static void onExit( @Advice.Thrown @Nullable Throwable throwable, @Advice.Return @Nullable Future responseFuture) { if (adviceScope != null) { - adviceScope.end(action, cmd, responseFuture, throwable); + adviceScope.end(action, responseFuture, throwable); } } } diff --git a/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/TransactionInstrumentation.java b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/TransactionInstrumentation.java new file mode 100644 index 000000000000..6036842e2348 --- /dev/null +++ b/instrumentation/rediscala-1.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/TransactionInstrumentation.java @@ -0,0 +1,95 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.rediscala.v1_8; + +import static io.opentelemetry.javaagent.instrumentation.rediscala.v1_8.RediscalaSingletons.instrumenter; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.returns; + +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import javax.annotation.Nullable; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; +import redis.Operation; +import redis.commands.TransactionBuilder; +import scala.collection.immutable.Queue; +import scala.concurrent.Future; + +class TransactionInstrumentation implements TypeInstrumentation { + @Override + public ElementMatcher typeMatcher() { + return named("redis.commands.TransactionBuilder"); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + named("exec").and(returns(named("scala.concurrent.Future"))), + getClass().getName() + "$ExecAdvice"); + } + + @SuppressWarnings("unused") + public static class ExecAdvice { + public static class AdviceScope { + private final Context context; + private final Scope scope; + private final RediscalaRequest request; + + private AdviceScope(Context context, Scope scope, RediscalaRequest request) { + this.context = context; + this.scope = scope; + this.request = request; + } + + @Nullable + public static AdviceScope start(TransactionBuilder transactionBuilder) { + Queue> operations = transactionBuilder.operations().result(); + RediscalaRequest request = RediscalaRequest.createTransaction(operations); + Context parentContext = Context.current(); + if (!instrumenter().shouldStart(parentContext, request)) { + return null; + } + + Context context = instrumenter().start(parentContext, request); + return new AdviceScope(context, context.makeCurrent(), request); + } + + public void end( + TransactionBuilder transactionBuilder, + @Nullable Future responseFuture, + @Nullable Throwable throwable) { + scope.close(); + if (throwable != null || responseFuture == null) { + instrumenter().end(context, request, null, throwable); + } else { + responseFuture.onComplete( + new OnCompleteHandler(context, request), transactionBuilder.executionContext()); + } + } + } + + @Nullable + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static AdviceScope onEnter(@Advice.This TransactionBuilder transactionBuilder) { + return AdviceScope.start(transactionBuilder); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void onExit( + @Advice.This TransactionBuilder transactionBuilder, + @Advice.Enter @Nullable AdviceScope adviceScope, + @Advice.Thrown @Nullable Throwable throwable, + @Advice.Return @Nullable Future responseFuture) { + if (adviceScope != null) { + adviceScope.end(transactionBuilder, responseFuture, throwable); + } + } + } +} diff --git a/instrumentation/rediscala-1.8/javaagent/src/test/scala/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaClientTest.scala b/instrumentation/rediscala-1.8/javaagent/src/test/scala/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaClientTest.scala index 27eeed9acebb..083478b13428 100644 --- a/instrumentation/rediscala-1.8/javaagent/src/test/scala/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaClientTest.scala +++ b/instrumentation/rediscala-1.8/javaagent/src/test/scala/io/opentelemetry/javaagent/instrumentation/rediscala/v1_8/RediscalaClientTest.scala @@ -6,12 +6,14 @@ package rediscala import io.opentelemetry.api.trace.SpanKind.CLIENT +import io.opentelemetry.instrumentation.api.internal.SemconvStability.emitStableDatabaseSemconv import io.opentelemetry.instrumentation.testing.junit.db.SemconvStabilityUtil.maybeStable import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension import io.opentelemetry.instrumentation.testing.junit.db.DbClientMetricsTestUtil.assertDurationMetric import io.opentelemetry.instrumentation.testing.util.ThrowingSupplier import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo import io.opentelemetry.sdk.testing.assertj.{SpanDataAssert, TraceAssert} +import io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE import io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_OPERATION import io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM import io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.REDIS @@ -19,10 +21,14 @@ import io.opentelemetry.semconv.DbAttributes.{DB_OPERATION_NAME, DB_SYSTEM_NAME} import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.{AfterAll, BeforeAll, Test, TestInstance} import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource import org.testcontainers.containers.GenericContainer +import redis.commands.TransactionBuilder import redis.{RedisClient, RedisDispatcher} import java.util.function.Consumer +import java.util.stream.Stream import scala.concurrent.duration.Duration import scala.concurrent.{Await, Future} @@ -195,4 +201,120 @@ class RediscalaClientTest { ) }) } + + @ParameterizedTest(name = "{0}") + @MethodSource(Array("transactionScenarios")) + def testTransaction(scenario: BatchScenario): Unit = { + val result = testing.runWithSpan( + "parent", + new ThrowingSupplier[Future[_], Exception] { + override def get(): Future[_] = { + val transaction = redisClient.multi() + scenario.commands(transaction) + transaction.exec() + } + } + ) + + Await.result(result, Duration.apply("3 second")) + + assertTransactionSpan(scenario.operation, scenario.batchSize) + } + + private def transactionScenarios(): Stream[BatchScenario] = { + Stream.of( + BatchScenario + .builder("single") + .commands(transaction => transaction.set("transaction-single", "value")) + .operation("MULTI SET") + .build(), + BatchScenario + .builder("twoSameOperation") + .commands((transaction: TransactionBuilder) => { + transaction.set("transaction-same-1", "value") + transaction.set("transaction-same-2", "value") + }) + .operation("MULTI SET") + .batchSize(2) + .build(), + BatchScenario + .builder("twoDifferentOperations") + .commands((transaction: TransactionBuilder) => { + transaction.set("transaction-different", "value") + transaction.get[String]("transaction-different") + }) + .operation("MULTI") + .batchSize(2) + .build() + ) + } + + private def assertTransactionSpan( + operation: String, + batchSize: java.lang.Long + ): Unit = { + testing.waitAndAssertTraces(new Consumer[TraceAssert] { + override def accept(trace: TraceAssert): Unit = + trace.hasSpansSatisfyingExactly( + new Consumer[SpanDataAssert] { + override def accept(span: SpanDataAssert): Unit = { + span.hasName("parent").hasNoParent + } + }, + new Consumer[SpanDataAssert] { + override def accept(span: SpanDataAssert): Unit = { + span + .hasName(operation) + .hasKind(CLIENT) + .hasParent(trace.getSpan(0)) + .hasAttributesSatisfyingExactly( + equalTo(maybeStable(DB_SYSTEM), REDIS), + equalTo(maybeStable(DB_OPERATION), operation), + equalTo( + DB_OPERATION_BATCH_SIZE, + if (emitStableDatabaseSemconv()) batchSize else null + ) + ) + } + } + ) + }) + } + + private class BatchScenario private ( + val name: String, + val commands: TransactionBuilder => Unit, + val operation: String, + val batchSize: java.lang.Long + ) { + override def toString: String = name + } + + private object BatchScenario { + def builder(name: String): Builder = new Builder(name) + + class Builder private[BatchScenario] (name: String) { + private var commands: TransactionBuilder => Unit = _ + private var operation: String = _ + private var batchSize: java.lang.Long = _ + + def commands(commands: TransactionBuilder => Unit): Builder = { + this.commands = commands + this + } + + def operation(operation: String): Builder = { + this.operation = operation + this + } + + def batchSize(batchSize: Long): Builder = { + this.batchSize = batchSize + this + } + + def build(): BatchScenario = + new BatchScenario(name, commands, operation, batchSize) + } + } } diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonAsyncClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonAsyncClientTest.java index 9f37859778d7..6ffeb41a2af5 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonAsyncClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonAsyncClientTest.java @@ -125,6 +125,7 @@ void setup(TestInfo testInfo) throws InvocationTargetException, IllegalAccessExc void cleanup() { if (redisson != null) { redisson.shutdown(); + testing.clearData(); } } @@ -241,7 +242,7 @@ void atomicBatchCommand() { assertThat(result.toCompletableFuture()).succeedsWithin(TIMEOUT); testing.waitAndAssertSortedTraces( - orderByRootSpanName("parent", "SADD", "callback"), + orderByRootSpanName("parent", "DB Query", "callback"), trace -> trace.hasSpansSatisfyingExactly( span -> span.hasName("parent").hasKind(INTERNAL).hasNoParent(), @@ -261,28 +262,6 @@ void atomicBatchCommand() { // does not represent the full logical batch. equalTo(maybeStable(DB_STATEMENT), "MULTI;SET batch1 ?")) .hasParent(trace.getSpan(0)), - span -> - span.hasName("SET") - .hasKind(CLIENT) - .hasAttributesSatisfyingExactly( - equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), - equalTo(NETWORK_PEER_ADDRESS, ip), - equalTo(NETWORK_PEER_PORT, port), - equalTo(maybeStable(DB_SYSTEM), REDIS), - equalTo(maybeStable(DB_STATEMENT), "SET batch2 ?"), - equalTo(maybeStable(DB_OPERATION), "SET")) - .hasParent(trace.getSpan(0)), - span -> - span.hasName("EXEC") - .hasKind(CLIENT) - .hasAttributesSatisfyingExactly( - equalTo(NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), - equalTo(NETWORK_PEER_ADDRESS, ip), - equalTo(NETWORK_PEER_PORT, port), - equalTo(maybeStable(DB_SYSTEM), REDIS), - equalTo(maybeStable(DB_STATEMENT), "EXEC"), - equalTo(maybeStable(DB_OPERATION), "EXEC")) - .hasParent(trace.getSpan(0)), span -> span.hasName("callback").hasKind(INTERNAL).hasParent(trace.getSpan(0)))); } diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index 12af9b9593aa..076739f85d20 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -145,6 +145,7 @@ void setup(TestInfo testInfo) throws InvocationTargetException, IllegalAccessExc void cleanup() { if (redisson != null) { redisson.shutdown(); + testing.clearData(); } } diff --git a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/RedisStandaloneConnectionInstrumentation.java b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/RedisStandaloneConnectionInstrumentation.java index 20051f946891..2b9a6b350481 100644 --- a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/RedisStandaloneConnectionInstrumentation.java +++ b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/RedisStandaloneConnectionInstrumentation.java @@ -20,6 +20,7 @@ import io.vertx.redis.client.impl.RedisStandaloneConnection; import io.vertx.redis.client.impl.RedisURI; import io.vertx.redis.client.impl.RequestUtil; +import java.util.List; import javax.annotation.Nullable; import net.bytebuddy.asm.Advice; import net.bytebuddy.asm.Advice.AssignReturned; @@ -35,6 +36,7 @@ public ElementMatcher typeMatcher() { @Override public void transform(TypeTransformer transformer) { transformer.applyAdviceToMethod(named("send"), getClass().getName() + "$SendAdvice"); + transformer.applyAdviceToMethod(named("batch"), getClass().getName() + "$BatchAdvice"); transformer.applyAdviceToMethod(isConstructor(), getClass().getName() + "$ConstructorAdvice"); } @@ -116,6 +118,90 @@ public static Future onExit( } } + @SuppressWarnings("unused") + public static class BatchAdvice { + public static class BatchAdviceScope { + private final VertxRedisClientRequest otelRequest; + private final Context context; + private final Scope scope; + + private BatchAdviceScope(VertxRedisClientRequest otelRequest, Context context, Scope scope) { + this.otelRequest = otelRequest; + this.context = context; + this.scope = scope; + } + + @Nullable + public static BatchAdviceScope start( + RedisStandaloneConnection connection, + @Nullable List requests, + NetSocket netSocket) { + + if (requests == null || requests.isEmpty()) { + return null; + } + for (Request request : requests) { + if (request == null + || VertxRedisClientSingletons.getCommandName(request.command()) == null) { + return null; + } + } + + RedisURI redisUri = VertxRedisClientSingletons.getRedisUri(connection); + if (redisUri == null) { + return null; + } + + VertxRedisClientRequest otelRequest = + VertxRedisClientRequest.createBatch(requests, redisUri, netSocket); + Context parentContext = Context.current(); + if (!instrumenter().shouldStart(parentContext, otelRequest)) { + return null; + } + Context context = instrumenter().start(parentContext, otelRequest); + return new BatchAdviceScope(otelRequest, context, context.makeCurrent()); + } + + @Nullable + public Future> end( + @Nullable Future> responseFuture, @Nullable Throwable throwable) { + scope.close(); + if (throwable != null) { + instrumenter().end(context, otelRequest, null, throwable); + } else { + responseFuture = + VertxRedisClientSingletons.wrapEndSpan(responseFuture, context, otelRequest); + } + return responseFuture; + } + } + + @Nullable + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static BatchAdviceScope onEnter( + @Advice.This RedisStandaloneConnection connection, + @Advice.Argument(0) @Nullable List requests, + @Advice.FieldValue("netSocket") NetSocket netSocket) { + + return BatchAdviceScope.start(connection, requests, netSocket); + } + + @Nullable + @AssignReturned.ToReturned + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static Future> onExit( + @Advice.Thrown @Nullable Throwable throwable, + @Advice.Return @Nullable Future> responseFuture, + @Advice.Enter @Nullable BatchAdviceScope adviceScope) { + + if (adviceScope != null) { + return adviceScope.end(responseFuture, throwable); + } + + return responseFuture; + } + } + @SuppressWarnings("unused") public static class ConstructorAdvice { @Advice.OnMethodExit(suppress = Throwable.class, inline = false) diff --git a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientAttributesGetter.java b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientAttributesGetter.java index b65d073a1e57..3f97e5b97b71 100644 --- a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientAttributesGetter.java +++ b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientAttributesGetter.java @@ -12,6 +12,7 @@ import io.opentelemetry.instrumentation.api.incubator.semconv.db.DbClientAttributesGetter; import io.opentelemetry.instrumentation.api.incubator.semconv.db.RedisCommandSanitizer; import io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues; +import java.util.List; import javax.annotation.Nullable; class VertxRedisClientAttributesGetter @@ -21,6 +22,10 @@ class VertxRedisClientAttributesGetter RedisCommandSanitizer.create( DbConfig.isQuerySanitizationEnabled(GlobalOpenTelemetry.get(), "vertx_redis_client")); + static String sanitize(String command, List args) { + return sanitizer.sanitize(command, args); + } + @Override public String getDbSystemName(VertxRedisClientRequest request) { return DbSystemNameIncubatingValues.REDIS; @@ -52,7 +57,11 @@ public String getConnectionString(VertxRedisClientRequest request) { @Override public String getDbQueryText(VertxRedisClientRequest request) { - return sanitizer.sanitize(request.getCommand(), request.getArgs()); + String queryText = request.getQueryTextOverride(); + if (queryText != null) { + return queryText; + } + return sanitize(request.getCommand(), request.getArgs()); } @Nullable @@ -61,6 +70,12 @@ public String getDbOperationName(VertxRedisClientRequest request) { return request.getCommand(); } + @Override + @Nullable + public Long getDbOperationBatchSize(VertxRedisClientRequest request) { + return request.getBatchSize(); + } + @Nullable @Override public String getServerAddress(VertxRedisClientRequest request) { diff --git a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientRequest.java b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientRequest.java index 15d2b603cea8..922aac97133f 100644 --- a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientRequest.java +++ b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientRequest.java @@ -6,9 +6,12 @@ package io.opentelemetry.javaagent.instrumentation.vertx.redisclient.v4_0; import io.vertx.core.net.NetSocket; +import io.vertx.redis.client.Request; import io.vertx.redis.client.impl.RedisURI; +import io.vertx.redis.client.impl.RequestUtil; import java.util.List; import java.util.Locale; +import java.util.StringJoiner; import javax.annotation.Nullable; class VertxRedisClientRequest { @@ -16,13 +19,38 @@ class VertxRedisClientRequest { private final List args; private final RedisURI redisUri; private final NetSocket netSocket; + @Nullable private final String queryTextOverride; + @Nullable private final Long batchSize; VertxRedisClientRequest( String command, List args, RedisURI redisUri, NetSocket netSocket) { + this(command, args, redisUri, netSocket, null, null); + } + + private VertxRedisClientRequest( + String command, + List args, + RedisURI redisUri, + NetSocket netSocket, + @Nullable String queryTextOverride, + @Nullable Long batchSize) { this.command = command.toUpperCase(Locale.ROOT); this.args = args; this.redisUri = redisUri; this.netSocket = netSocket; + this.queryTextOverride = queryTextOverride; + this.batchSize = batchSize; + } + + static VertxRedisClientRequest createBatch( + List requests, RedisURI redisUri, NetSocket netSocket) { + return new VertxRedisClientRequest( + batchOperationName(requests), + RequestUtil.getArgs(requests.get(0)), + redisUri, + netSocket, + batchQueryText(requests), + requests.size() > 1 ? (long) requests.size() : null); } String getCommand() { @@ -33,6 +61,16 @@ List getArgs() { return args; } + @Nullable + String getQueryTextOverride() { + return queryTextOverride; + } + + @Nullable + Long getBatchSize() { + return batchSize; + } + @Nullable String getUser() { return redisUri.user(); @@ -68,4 +106,29 @@ Integer getPeerPort() { int port = netSocket.remoteAddress().port(); return port != -1 ? port : null; } + + private static String batchOperationName(List requests) { + String operationName = VertxRedisClientSingletons.getCommandName(requests.get(0).command()); + if (requests.size() == 1) { + return operationName; + } + for (int i = 1; i < requests.size(); i++) { + if (!operationName.equals( + VertxRedisClientSingletons.getCommandName(requests.get(i).command()))) { + return "PIPELINE"; + } + } + return "PIPELINE " + operationName; + } + + private static String batchQueryText(List requests) { + StringJoiner joiner = new StringJoiner(";"); + for (Request request : requests) { + String commandName = + VertxRedisClientSingletons.getCommandName(request.command()).toUpperCase(Locale.ROOT); + joiner.add( + VertxRedisClientAttributesGetter.sanitize(commandName, RequestUtil.getArgs(request))); + } + return joiner.toString(); + } } diff --git a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientTest.java b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientTest.java index 6a4f21541d40..b53f8f075f89 100644 --- a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientTest.java +++ b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientTest.java @@ -10,6 +10,7 @@ import static io.opentelemetry.instrumentation.testing.junit.service.SemconvServiceStabilityUtil.maybeStablePeerService; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; import static io.opentelemetry.semconv.DbAttributes.DB_NAMESPACE; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_NAME; import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_TEXT; import static io.opentelemetry.semconv.DbAttributes.DB_SYSTEM_NAME; @@ -32,14 +33,20 @@ import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; import io.opentelemetry.sdk.testing.assertj.AttributeAssertion; import io.vertx.core.Vertx; +import io.vertx.redis.client.Command; import io.vertx.redis.client.Redis; import io.vertx.redis.client.RedisAPI; import io.vertx.redis.client.RedisConnection; +import io.vertx.redis.client.Request; import java.net.InetAddress; +import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.GenericContainer; @SuppressWarnings("deprecation") // using deprecated semconv @@ -201,7 +208,58 @@ void commandWithNoArguments() throws Exception { redisSpanAttributes("RANDOMKEY", "RANDOMKEY")))); } + @ParameterizedTest(name = "{0}") + @MethodSource("batchScenarios") + void batchCommand(BatchScenario scenario) throws Exception { + testing.clearData(); + + client.batch(scenario.requests).toCompletionStage().toCompletableFuture().get(30, SECONDS); + + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName( + emitStableDatabaseSemconv() + ? scenario.operation + " " + host + ":" + port + : scenario.operation) + .hasKind(SpanKind.CLIENT) + .hasAttributesSatisfyingExactly( + redisSpanAttributes( + scenario.operation, scenario.statement, scenario.batchSize)))); + } + + private static Stream batchScenarios() { + return Stream.of( + BatchScenario.builder("single") + .requests(Request.cmd(Command.SET).arg("batch1").arg("v1")) + .operation("SET") + .statement("SET batch1 ?") + .build(), + BatchScenario.builder("twoSameOperation") + .requests( + Request.cmd(Command.SET).arg("batch1").arg("v1"), + Request.cmd(Command.SET).arg("batch2").arg("v2")) + .operation("PIPELINE SET") + .statement("SET batch1 ?;SET batch2 ?") + .batchSize(2) + .build(), + BatchScenario.builder("twoDifferentOperations") + .requests( + Request.cmd(Command.SET).arg("batch1").arg("v1"), + Request.cmd(Command.GET).arg("batch1")) + .operation("PIPELINE") + .statement("SET batch1 ?;GET batch1") + .batchSize(2) + .build()); + } + private static AttributeAssertion[] redisSpanAttributes(String operation, String queryText) { + return redisSpanAttributes(operation, queryText, null); + } + + private static AttributeAssertion[] redisSpanAttributes( + String operation, String queryText, Long batchSize) { // not testing database/dup if (emitStableDatabaseSemconv()) { return new AttributeAssertion[] { @@ -209,6 +267,7 @@ private static AttributeAssertion[] redisSpanAttributes(String operation, String equalTo(DB_QUERY_TEXT, queryText), equalTo(DB_OPERATION_NAME, operation), equalTo(DB_NAMESPACE, "1"), + equalTo(DB_OPERATION_BATCH_SIZE, batchSize), equalTo(SERVER_ADDRESS, host), equalTo(SERVER_PORT, port), equalTo(maybeStablePeerService(), "test-peer-service"), @@ -229,4 +288,66 @@ private static AttributeAssertion[] redisSpanAttributes(String operation, String }; } } + + private static class BatchScenario { + private final String name; + private final List requests; + private final String operation; + private final String statement; + private final Long batchSize; + + private BatchScenario( + String name, List requests, String operation, String statement, Long batchSize) { + this.name = name; + this.requests = requests; + this.operation = operation; + this.statement = statement; + this.batchSize = batchSize; + } + + private static Builder builder(String name) { + return new Builder(name); + } + + @Override + public String toString() { + return name; + } + + private static class Builder { + private final String name; + private List requests; + private String operation; + private String statement; + private Long batchSize; + + private Builder(String name) { + this.name = name; + } + + private Builder requests(Request... requests) { + this.requests = asList(requests); + return this; + } + + private Builder operation(String operation) { + this.operation = operation; + return this; + } + + private Builder statement(String statement) { + this.statement = statement; + return this; + } + + private Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + private BatchScenario build() { + return new BatchScenario(name, requests, operation, statement, batchSize); + } + } + } } From 12db81e678562cd9e3deb0b603e17ca48e9ddf77 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Wed, 17 Jun 2026 16:44:47 -0700 Subject: [PATCH 25/31] Fix Redis batch test compatibility --- .../v1_4/JedisPipelineInstrumentation.java | 91 ++++++++++++++++++- .../jedis/AbstractJedisTest.java | 71 ++++++++++++--- .../jedis/Jedis14PipelineRunner.java | 43 +++++++++ .../core/protocol/OtelCommandArgsUtil.java | 33 +------ 4 files changed, 196 insertions(+), 42 deletions(-) create mode 100644 instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/Jedis14PipelineRunner.java diff --git a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java index 25fdfc6ae665..7c6c67736963 100644 --- a/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java +++ b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java @@ -5,12 +5,23 @@ package io.opentelemetry.javaagent.instrumentation.jedis.v1_4; +import static io.opentelemetry.javaagent.bootstrap.Java8BytecodeBridge.currentContext; +import static io.opentelemetry.javaagent.instrumentation.jedis.v1_4.JedisSingletons.instrumenter; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.isPublic; import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.namedOneOf; +import static net.bytebuddy.matcher.ElementMatchers.returns; import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; import io.opentelemetry.javaagent.instrumentation.jedis.common.v1_4.JedisPipelineContext; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; import net.bytebuddy.asm.Advice; import net.bytebuddy.description.type.TypeDescription; import net.bytebuddy.matcher.ElementMatcher; @@ -18,7 +29,11 @@ class JedisPipelineInstrumentation implements TypeInstrumentation { @Override public ElementMatcher typeMatcher() { - return named("redis.clients.jedis.Jedis"); + return namedOneOf( + "redis.clients.jedis.Jedis", + "redis.clients.jedis.Pipeline", + "redis.clients.jedis.PipelineBase", + "redis.clients.jedis.MultiKeyPipelineBase"); } @Override @@ -26,6 +41,11 @@ public void transform(TypeTransformer transformer) { transformer.applyAdviceToMethod( named("pipelined").and(takesArgument(0, named("redis.clients.jedis.JedisPipeline"))), getClass().getName() + "$PipelinedAdvice"); + transformer.applyAdviceToMethod( + isPublic().and(isMethod()).and(returns(named("redis.clients.jedis.Response"))), + getClass().getName() + "$QueueCommandAdvice"); + transformer.applyAdviceToMethod( + namedOneOf("sync", "syncAndReturnAll"), getClass().getName() + "$SyncAdvice"); } @SuppressWarnings("unused") @@ -41,4 +61,73 @@ public static void onExit() { JedisPipelineContext.exit(); } } + + @SuppressWarnings("unused") + public static class QueueCommandAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static void onEnter(@Advice.This Object pipeline) { + JedisPipelineContext.enter(pipeline); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void stopCollecting() { + JedisPipelineContext.exit(); + } + } + + @SuppressWarnings("unused") + public static class SyncAdvice { + + public static class AdviceScope { + private final Context context; + private final Scope scope; + private final JedisRequest request; + + private AdviceScope(Context context, Scope scope, JedisRequest request) { + this.context = context; + this.scope = scope; + this.request = request; + } + + @Nullable + public static AdviceScope start(Object pipeline) { + List capturedRequests = JedisPipelineContext.drain(pipeline); + if (capturedRequests.isEmpty()) { + return null; + } + List requests = new ArrayList<>(capturedRequests.size()); + for (Object capturedRequest : capturedRequests) { + requests.add((JedisRequest) capturedRequest); + } + JedisRequest request = JedisRequest.createPipeline(requests); + Context parentContext = currentContext(); + if (!instrumenter().shouldStart(parentContext, request)) { + return null; + } + Context context = instrumenter().start(parentContext, request); + return new AdviceScope(context, context.makeCurrent(), request); + } + + public void end(@Nullable Throwable throwable) { + scope.close(); + instrumenter().end(context, request, null, throwable); + } + } + + @Nullable + @Advice.OnMethodEnter(suppress = Throwable.class, inline = false) + public static AdviceScope onEnter(@Advice.This Object pipeline) { + return AdviceScope.start(pipeline); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false) + public static void stopSpan( + @Advice.Thrown @Nullable Throwable throwable, + @Advice.Enter @Nullable AdviceScope adviceScope) { + if (adviceScope != null) { + adviceScope.end(throwable); + } + } + } } diff --git a/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/AbstractJedisTest.java b/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/AbstractJedisTest.java index 397104b46d61..e110682ea542 100644 --- a/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/AbstractJedisTest.java +++ b/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/AbstractJedisTest.java @@ -37,9 +37,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.GenericContainer; -import redis.clients.jedis.Client; import redis.clients.jedis.Jedis; -import redis.clients.jedis.JedisPipeline; @SuppressWarnings("deprecation") // using deprecated semconv public abstract class AbstractJedisTest { @@ -177,14 +175,9 @@ void commandWithNoArguments() { @ParameterizedTest(name = "{0}") @MethodSource("pipelineScenarios") void pipelineCommand( - String name, PipelineScenario scenario, List expectedCommands) { - jedis.pipelined( - new JedisPipeline() { - @Override - public void execute() { - scenario.run(client); - } - }); + String name, PipelineScenario scenario, List expectedCommands) + throws ReflectiveOperationException { + runPipeline(scenario); if (expectedCommands.isEmpty()) { assertThat(testing.spans()).isEmpty(); @@ -274,8 +267,62 @@ private static ExpectedCommand expectedCommand(String operation, String statemen return new ExpectedCommand(operation, statement); } - private interface PipelineScenario { - void run(Client client); + private static void runPipeline(PipelineScenario scenario) throws ReflectiveOperationException { + if (hasJedisPipelineCallback()) { + Jedis14PipelineRunner.run(jedis, scenario); + return; + } + Object pipeline = Jedis.class.getMethod("pipelined").invoke(jedis); + scenario.run(new ReflectivePipelineOperations(pipeline)); + pipeline.getClass().getMethod("sync").invoke(pipeline); + } + + private static boolean hasJedisPipelineCallback() { + try { + Class.forName("redis.clients.jedis.JedisPipeline"); + return true; + } catch (ClassNotFoundException ignored) { + return false; + } + } + + interface PipelineScenario { + void run(PipelineOperations pipeline); + } + + interface PipelineOperations { + void set(String key, String value); + + void get(String key); + } + + private static class ReflectivePipelineOperations implements PipelineOperations { + private final Object pipeline; + + private ReflectivePipelineOperations(Object pipeline) { + this.pipeline = pipeline; + } + + @Override + public void set(String key, String value) { + try { + pipeline + .getClass() + .getMethod("set", String.class, String.class) + .invoke(pipeline, key, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + @Override + public void get(String key) { + try { + pipeline.getClass().getMethod("get", String.class).invoke(pipeline, key); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } } private static class ExpectedCommand { diff --git a/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/Jedis14PipelineRunner.java b/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/Jedis14PipelineRunner.java new file mode 100644 index 000000000000..64fb0381d355 --- /dev/null +++ b/instrumentation/jedis/jedis-1.4/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/Jedis14PipelineRunner.java @@ -0,0 +1,43 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.jedis; + +import redis.clients.jedis.Client; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPipeline; + +class Jedis14PipelineRunner { + + static void run(Jedis jedis, AbstractJedisTest.PipelineScenario scenario) { + jedis.pipelined( + new JedisPipeline() { + @Override + public void execute() { + scenario.run(new ClientPipelineOperations(client)); + } + }); + } + + private static class ClientPipelineOperations implements AbstractJedisTest.PipelineOperations { + private final Client client; + + private ClientPipelineOperations(Client client) { + this.client = client; + } + + @Override + public void set(String key, String value) { + client.set(key, value); + } + + @Override + public void get(String key) { + client.get(key); + } + } + + private Jedis14PipelineRunner() {} +} diff --git a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/lettuce/core/protocol/OtelCommandArgsUtil.java b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/lettuce/core/protocol/OtelCommandArgsUtil.java index 98fe8b7cc98b..1d9ea560ab0d 100644 --- a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/lettuce/core/protocol/OtelCommandArgsUtil.java +++ b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/lettuce/core/protocol/OtelCommandArgsUtil.java @@ -5,43 +5,18 @@ package io.lettuce.core.protocol; -import io.lettuce.core.codec.StringCodec; -import io.lettuce.core.protocol.CommandArgs.KeyArgument; -import io.lettuce.core.protocol.CommandArgs.SingularArgument; -import io.lettuce.core.protocol.CommandArgs.ValueArgument; import io.opentelemetry.instrumentation.lettuce.common.LettuceArgSplitter; -import java.util.ArrayList; import java.util.List; -// Helper class for accessing package private fields in CommandArgs and its inner classes. -// https://github.com/lettuce-io/lettuce-core/blob/main/src/main/java/io/lettuce/core/protocol/CommandArgs.java public final class OtelCommandArgsUtil { /** - * Extract argument {@link List} from {@link CommandArgs} so that we wouldn't need to parse them - * from command {@link String} with {@link LettuceArgSplitter#splitArgs}. + * Extract argument {@link List} from {@link CommandArgs} using public API only. Helper classes + * can be loaded by a different class loader than Lettuce, so package-private field access is not + * safe even from the same package name. */ public static List getCommandArgs(CommandArgs commandArgs) { - List result = new ArrayList<>(); - - for (SingularArgument argument : commandArgs.singularArguments) { - String value = getArgValue(StringCodec.UTF8, argument); - result.add(value); - } - return result; - } - - @SuppressWarnings("unchecked") // type is checked before casting - private static String getArgValue(StringCodec stringCodec, SingularArgument argument) { - if (argument instanceof KeyArgument) { - KeyArgument keyArg = (KeyArgument) argument; - return stringCodec.decodeKey(keyArg.codec.encodeKey(keyArg.key)); - } - if (argument instanceof ValueArgument) { - ValueArgument valueArg = (ValueArgument) argument; - return stringCodec.decodeValue(valueArg.codec.encodeValue(valueArg.val)); - } - return argument.toString(); + return LettuceArgSplitter.splitArgs(commandArgs.toCommandString()); } private OtelCommandArgsUtil() {} From ef306368de1910cd743a0c15153b4d1b36aa8bc5 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Wed, 17 Jun 2026 17:58:36 -0700 Subject: [PATCH 26/31] Fix Lettuce cancellation test span ordering --- .../instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/lettuce/lettuce-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java b/instrumentation/lettuce/lettuce-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java index f74f4b78947f..26c91007e982 100644 --- a/instrumentation/lettuce/lettuce-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java +++ b/instrumentation/lettuce/lettuce-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_0/LettuceAsyncClientTest.java @@ -469,7 +469,7 @@ void testCancelCommandBeforeItFinishes() { await().untilAsserted(() -> assertThat(cancelSuccess).isTrue()); testing.waitAndAssertTraces( trace -> - trace.hasSpansSatisfyingExactly( + trace.hasSpansSatisfyingExactlyInAnyOrder( span -> span.hasName("parent") .hasKind(SpanKind.INTERNAL) From 3dbf14e6e79ccaaa0a781d19a62ee9ecb62d1e1f Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Wed, 17 Jun 2026 21:18:12 -0700 Subject: [PATCH 27/31] Align DynamoDB and Redisson batch telemetry --- .../internal/DynamoDbAttributesExtractor.java | 32 +----------- .../v1_11/AbstractDynamoDbClientTest.java | 14 +++--- .../internal/DynamoDbAttributesExtractor.java | 33 ++----------- .../v2_2/AbstractAws2ClientCoreTest.java | 16 ++---- .../common/v3_0/RedissonBatchSpanManager.java | 30 ++++++++++++ .../redisson/common/v3_0/RedissonRequest.java | 49 ++++++++++++++++--- .../AbstractRedissonAsyncClientTest.java | 8 +-- .../redisson/AbstractRedissonClientTest.java | 6 +-- 8 files changed, 95 insertions(+), 93 deletions(-) diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java index 2443dec53d5d..b902244a6467 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/internal/DynamoDbAttributesExtractor.java @@ -107,9 +107,6 @@ private static String getSingleCollectionName(Map requestItems) { @Nullable private static String getStableOperationName( @Nullable String operation, @Nullable Long batchSize, int writeOpType) { - if ("BatchGetItem".equals(operation)) { - return getStableBatchOperationName(batchSize, "GetItem", operation); - } if ("BatchWriteItem".equals(operation)) { return getStableWriteOperationName(batchSize, writeOpType); } @@ -131,20 +128,9 @@ private static String getStableWriteOperationName(@Nullable Long batchSize, int return "BATCH " + itemOp; } - private static String getStableBatchOperationName( - @Nullable Long batchSize, String itemOperation, String batchOperation) { - if (batchSize == null || batchSize == 0) { - return batchOperation; - } - if (batchSize == 1) { - return itemOperation; - } - return "BATCH " + itemOperation; - } - @Nullable private static Long extractBatchSize(@Nullable String operation, Object request) { - if (!"BatchGetItem".equals(operation) && !"BatchWriteItem".equals(operation)) { + if (!"BatchWriteItem".equals(operation)) { return null; } @@ -153,25 +139,11 @@ private static Long extractBatchSize(@Nullable String operation, Object request) return null; } - long batchSize = - "BatchGetItem".equals(operation) - ? countBatchGetItems(requestItems) - : countBatchWriteItems(requestItems); + long batchSize = countBatchWriteItems(requestItems); // return the size for every batch request, including an empty batch with size 0 return batchSize; } - private static long countBatchGetItems(Map requestItems) { - long count = 0; - for (Object keysAndAttributes : requestItems.values()) { - List keys = RequestAccess.getKeys(keysAndAttributes); - if (keys != null) { - count += keys.size(); - } - } - return count; - } - private static long countBatchWriteItems(Map requestItems) { long count = 0; for (Object writeRequests : requestItems.values()) { diff --git a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java index 1bd052fcdb56..79ef381c4a10 100644 --- a/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java +++ b/instrumentation/aws-sdk/aws-sdk-1.11/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v1_11/AbstractDynamoDbClientTest.java @@ -92,7 +92,8 @@ void sendRequestWithMockedResponse() throws ReflectiveOperationException { // describes the batch cases for the two DynamoDB batch operations (BatchGetItem and // BatchWriteItem): the request to send and the expected client span. batch attributes // (db.operation.batch.size, BATCH operation name, db.collection.name) are only emitted under - // stable database semconv; the span and db.operation.name are emitted in both modes + // stable database semconv for BatchWriteItem, whose request entries represent explicit write + // operations. BatchGetItem request entries are keys, so they do not produce batch telemetry. @SuppressWarnings("deprecation") // using deprecated semconv @ParameterizedTest @MethodSource("batchScenarios") @@ -123,26 +124,23 @@ void batchOperation(BatchScenario scenario) throws ReflectiveOperationException private static Stream batchScenarios() { return Stream.of( - // an empty batch keeps the raw batch operation name and carries db.operation.batch.size 0, - // but no collection name + // BatchGetItem entries are keys, not explicit operations, so the stable operation name + // remains the raw batch operation and db.operation.batch.size is not emitted. BatchScenario.builder("getItemEmpty") .awsOperation("BatchGetItem") .execute(client -> client.batchGetItem(getItemRequest(0))) .stableOperation("BatchGetItem") - .batchSize(0) .build(), - // a single-item batch is not a batch, so it uses the singular item operation BatchScenario.builder("getItemSingle") .awsOperation("BatchGetItem") .execute(client -> client.batchGetItem(getItemRequest(1))) - .stableOperation("GetItem") + .stableOperation("BatchGetItem") .hasCollection() .build(), BatchScenario.builder("getItemTwo") .awsOperation("BatchGetItem") .execute(client -> client.batchGetItem(getItemRequest(2))) - .stableOperation("BATCH GetItem") - .batchSize(2) + .stableOperation("BatchGetItem") .hasCollection() .build(), BatchScenario.builder("writeItemEmpty") diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java b/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java index a8980e18b641..574906cc0497 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/library/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/internal/DynamoDbAttributesExtractor.java @@ -80,9 +80,6 @@ public void onStart( @Nullable private static String getStableOperationName( @Nullable String operation, @Nullable Long batchSize, int writeOpType) { - if ("BatchGetItem".equals(operation)) { - return getStableBatchOperationName(batchSize, "GetItem", operation); - } if ("BatchWriteItem".equals(operation)) { return getStableWriteOperationName(batchSize, writeOpType); } @@ -104,21 +101,10 @@ private static String getStableWriteOperationName(@Nullable Long batchSize, int return "BATCH " + itemOp; } - private static String getStableBatchOperationName( - @Nullable Long batchSize, String itemOperation, String batchOperation) { - if (batchSize == null || batchSize == 0) { - return batchOperation; - } - if (batchSize == 1) { - return itemOperation; - } - return "BATCH " + itemOperation; - } - @Nullable - private Long extractBatchSize( + private static Long extractBatchSize( @Nullable String operation, ExecutionAttributes executionAttributes) { - if (!"BatchGetItem".equals(operation) && !"BatchWriteItem".equals(operation)) { + if (!"BatchWriteItem".equals(operation)) { return null; } @@ -133,20 +119,7 @@ private Long extractBatchSize( } Map requestItemsMap = (Map) requestItems.get(); - return "BatchGetItem".equals(operation) - ? countBatchGetItems(requestItemsMap) - : countBatchWriteItems(requestItemsMap); - } - - private long countBatchGetItems(Map requestItems) { - long count = 0; - for (Object keysAndAttributes : requestItems.values()) { - Object keys = next(keysAndAttributes, "Keys"); - if (keys instanceof Collection) { - count += ((Collection) keys).size(); - } - } - return count; + return countBatchWriteItems(requestItemsMap); } private static long countBatchWriteItems(Map requestItems) { diff --git a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java index c2ec4fa07349..1adcc824e061 100644 --- a/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java +++ b/instrumentation/aws-sdk/aws-sdk-2.2/testing/src/main/java/io/opentelemetry/instrumentation/awssdk/v2_2/AbstractAws2ClientCoreTest.java @@ -492,10 +492,6 @@ void testBatchGetItemWithMultipleTablesOmitsDbCollectionName() { .doesNotContainKey(DB_COLLECTION_NAME)))); } - // describes the batch cases for the two DynamoDB batch operations (BatchGetItem and - // BatchWriteItem): the request to send, the mocked response and the expected client span. batch - // attributes (db.operation.batch.size, BATCH operation name, db.collection.name) are only emitted - // under stable database semconv; the span and db.operation.name are emitted in both modes @ParameterizedTest @MethodSource("batchScenarios") @SuppressWarnings("deprecation") // uses deprecated semconv @@ -566,17 +562,14 @@ void batchOperation(BatchScenario scenario) { @SuppressWarnings("deprecation") // uses deprecated semconv private static Stream batchScenarios() { return Stream.of( - // an empty batch still produces a span with the raw batch operation name and - // db.operation.batch.size 0, but no db.collection.name or table-name attributes + // BatchGetItem entries are keys, not explicit operations, so the stable operation name + // remains the raw batch operation and db.operation.batch.size is not emitted. BatchScenario.builder("getItemEmpty") .awsOperation("BatchGetItem") .responseContent("{\"ConsumedCapacity\":[]}") .execute(c -> c.batchGetItem(b -> b.requestItems(ImmutableMap.of()))) .stableOperation("BatchGetItem") - .batchSize(0) .build(), - // a single-item batch is not a batch, so it uses the singular item operation and emits - // no db.operation.batch.size BatchScenario.builder("getItemSingle") .awsOperation("BatchGetItem") .responseContent(getResponseContent("BatchGetItem")) @@ -594,7 +587,7 @@ private static Stream batchScenarios() { "key", AttributeValue.builder().s("value").build()))) .build())))) - .stableOperation("GetItem") + .stableOperation("BatchGetItem") .hasCollection() .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") .assertMetric() @@ -621,9 +614,8 @@ private static Stream batchScenarios() { .s("anotherValue") .build()))) .build())))) - .stableOperation("BATCH GetItem") + .stableOperation("BatchGetItem") .hasCollection() - .batchSize(2) .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") .assertMetric() .build(), diff --git a/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonBatchSpanManager.java b/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonBatchSpanManager.java index fba4c3bbf464..d358c970856f 100644 --- a/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonBatchSpanManager.java +++ b/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonBatchSpanManager.java @@ -5,6 +5,14 @@ package io.opentelemetry.javaagent.instrumentation.redisson.common.v3_0; +import static io.opentelemetry.instrumentation.api.internal.SemconvStability.emitOldDatabaseSemconv; +import static io.opentelemetry.instrumentation.api.internal.SemconvStability.emitStableDatabaseSemconv; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_NAME; +import static io.opentelemetry.semconv.DbAttributes.DB_QUERY_TEXT; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Context; import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; import java.util.Collections; @@ -13,6 +21,9 @@ import javax.annotation.Nullable; public final class RedissonBatchSpanManager { + // copied from DbIncubatingAttributes + private static final AttributeKey DB_STATEMENT = AttributeKey.stringKey("db.statement"); + private static final Map activeMultiSpans = Collections.synchronizedMap(new WeakHashMap<>()); @@ -31,6 +42,10 @@ public static boolean suppressSpanOrEndMultiSpan( return false; } + if (!request.isExecCommand()) { + activeSpan.request.addCommandsFrom(request); + activeSpan.updateAttributes(); + } setEndOperationListener(connection, promise, activeSpan, request.isExecCommand()); return true; } @@ -77,5 +92,20 @@ private ActiveSpan( private void end(@Nullable Throwable error) { instrumenter.end(context, request, null, error); } + + private void updateAttributes() { + Span span = Span.fromContext(context); + if (emitStableDatabaseSemconv()) { + span.setAttribute(DB_OPERATION_NAME, request.getOperationName()); + span.setAttribute(DB_QUERY_TEXT, request.getQueryText()); + Long batchSize = request.getOperationBatchSize(); + if (batchSize != null) { + span.setAttribute(DB_OPERATION_BATCH_SIZE, batchSize); + } + } + if (emitOldDatabaseSemconv()) { + span.setAttribute(DB_STATEMENT, request.getQueryText()); + } + } } } diff --git a/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonRequest.java b/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonRequest.java index c8f66f37f17c..843dcd511bf8 100644 --- a/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonRequest.java +++ b/instrumentation/redisson/redisson-common-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/common/v3_0/RedissonRequest.java @@ -43,6 +43,8 @@ public abstract class RedissonRequest { // note that RedisCommandSanitizer already limits the size of sanitized commands private static final int LIMIT = 32 * 1024; + private final List> additionalCommands = new ArrayList<>(); + @Nullable private static final MethodHandle COMMAND_DATA_GET_PROMISE = findGetPromiseMethod(CommandData.class); @@ -83,14 +85,37 @@ public static RedissonRequest create(@Nullable InetSocketAddress address, Object public abstract Object getCommand(); + void addCommandsFrom(RedissonRequest request) { + if (!isMultiBatch()) { + return; + } + Object command = request.getCommand(); + if (command == getCommand()) { + return; + } + if (command instanceof CommandData) { + addCommand((CommandData) command); + } else if (command instanceof CommandsData) { + for (CommandData singleCommand : ((CommandsData) command).getCommands()) { + addCommand(singleCommand); + } + } + } + + private void addCommand(CommandData command) { + String commandName = command.getCommand().getName(); + if (!commandName.equals(MULTI) && !commandName.equals("EXEC")) { + additionalCommands.add(command); + } + } + @Nullable public String getOperationName() { Object command = getCommand(); if (command instanceof CommandData) { return ((CommandData) command).getCommand().getName(); } else if (command instanceof CommandsData) { - CommandsData commandsData = (CommandsData) command; - List> commands = commandsData.getCommands(); + List> commands = getCommands(); if (commands.size() == 1) { return commands.get(0).getCommand().getName(); } @@ -104,7 +129,7 @@ public boolean isMultiBatch() { if (!(command instanceof CommandsData)) { return false; } - List> commands = ((CommandsData) command).getCommands(); + List> commands = getCommands(); return !commands.isEmpty() && commands.get(0).getCommand().getName().equals(MULTI); } @@ -114,7 +139,7 @@ public boolean isExecCommand() { return ((CommandData) command).getCommand().getName().equals("EXEC"); } if (command instanceof CommandsData) { - for (CommandData singleCommand : ((CommandsData) command).getCommands()) { + for (CommandData singleCommand : getCommands()) { if (singleCommand.getCommand().getName().equals("EXEC")) { return true; } @@ -129,7 +154,7 @@ public Long getOperationBatchSize() { if (!(command instanceof CommandsData)) { return null; } - List> commands = ((CommandsData) command).getCommands(); + List> commands = getCommands(); if (commands.isEmpty()) { return null; } @@ -175,7 +200,7 @@ private List sanitizeQuery() { // get command if (command instanceof CommandsData) { int length = 0; - List> commands = ((CommandsData) command).getCommands(); + List> commands = getCommands(); List normalizedCommands = new ArrayList<>(commands.size()); for (CommandData singleCommand : commands) { String s = normalizeSingleCommand(singleCommand); @@ -233,6 +258,18 @@ private static String normalizeSingleCommand(CommandData command) { return sanitizer.sanitize(command.getCommand().getName(), args); } + private List> getCommands() { + List> commands = ((CommandsData) getCommand()).getCommands(); + if (additionalCommands.isEmpty()) { + return commands; + } + List> combinedCommands = + new ArrayList<>(commands.size() + additionalCommands.size()); + combinedCommands.addAll(commands); + combinedCommands.addAll(additionalCommands); + return combinedCommands; + } + @Nullable public PromiseWrapper getPromiseWrapper() { CompletionStage promise = getPromise(); diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonAsyncClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonAsyncClientTest.java index 6ffeb41a2af5..dde5b1024d4d 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonAsyncClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonAsyncClientTest.java @@ -14,6 +14,7 @@ import static io.opentelemetry.instrumentation.testing.util.TelemetryDataUtil.orderByRootSpanName; import static io.opentelemetry.instrumentation.testing.util.TestLatestDeps.testLatestDeps; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.semconv.DbAttributes.DB_OPERATION_BATCH_SIZE; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_PEER_ADDRESS; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_PEER_PORT; import static io.opentelemetry.semconv.NetworkAttributes.NETWORK_TYPE; @@ -257,10 +258,9 @@ void atomicBatchCommand() { equalTo( DB_OPERATION_NAME, emitStableDatabaseSemconv() ? "MULTI SET" : null), - // db.operation.batch.size is not emitted because MULTI transaction - // telemetry is split across wrapper and command spans, so this span - // does not represent the full logical batch. - equalTo(maybeStable(DB_STATEMENT), "MULTI;SET batch1 ?")) + equalTo( + DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), + equalTo(maybeStable(DB_STATEMENT), "MULTI;SET batch1 ?;SET batch2 ?")) .hasParent(trace.getSpan(0)), span -> span.hasName("callback").hasKind(INTERNAL).hasParent(trace.getSpan(0)))); } diff --git a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java index 076739f85d20..2b7660e20c7c 100644 --- a/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java +++ b/instrumentation/redisson/redisson-common-3.0/testing/src/main/java/io/opentelemetry/javaagent/instrumentation/redisson/AbstractRedissonClientTest.java @@ -415,9 +415,9 @@ void atomicBatchCommand() { equalTo( DB_OPERATION_NAME, emitStableDatabaseSemconv() ? "MULTI SET" : null), - // db.operation.batch.size is not emitted because MULTI transaction - // wrapper only sees MULTI plus the first queued command. - equalTo(maybeStable(DB_STATEMENT), "MULTI;SET batch1 ?")) + equalTo( + DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), + equalTo(maybeStable(DB_STATEMENT), "MULTI;SET batch1 ?;SET batch2 ?")) .hasParent(trace.getSpan(0)))); } From 9d3476e46a128a9e8a45734580cdde3194c074b3 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Wed, 17 Jun 2026 22:38:52 -0700 Subject: [PATCH 28/31] Handle empty database batches --- .../db/DbClientAttributesExtractor.java | 2 +- .../semconv/db/DbClientSpanNameExtractor.java | 5 +- .../db/SqlClientAttributesExtractor.java | 2 +- .../db/DbClientSpanNameExtractorTest.java | 70 +++++++++++++++++++ .../db/SqlClientAttributesExtractorTest.java | 51 ++++++++++++++ .../cassandra/v3_0/CassandraClientTest.java | 4 +- .../common/v4_0/AbstractCassandraTest.java | 4 +- .../AbstractJdbcInstrumentationTest.java | 6 +- .../LettuceAsyncCommandsInstrumentation.java | 7 +- .../v5_1/AbstractLettuceAsyncClientTest.java | 8 ++- .../v1_0/AbstractR2dbcStatementTest.java | 4 +- .../sqlclient/v4_0/VertxSqlClientTest.java | 4 +- .../sqlclient/v5_0/VertxSqlClientTest.java | 4 +- 13 files changed, 150 insertions(+), 21 deletions(-) diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientAttributesExtractor.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientAttributesExtractor.java index f2a28b2217c8..adba73d1a470 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientAttributesExtractor.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientAttributesExtractor.java @@ -94,10 +94,10 @@ static void onStartCommon( REQUEST request, boolean captureQueryParameters) { Long batchSize = getter.getDbOperationBatchSize(request); - boolean isBatch = batchSize != null && batchSize > 1; // db.operation.batch.size is captured for every batch execution (including an empty batch with // size 0); it is only omitted for a single-statement batch, which is reported as a non-batch boolean emitBatchSize = batchSize != null && batchSize != 1; + boolean isBatch = emitBatchSize; if (emitStableDatabaseSemconv()) { attributes.put( diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractor.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractor.java index 02bfdfc3720a..d1d35335054e 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractor.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractor.java @@ -193,6 +193,9 @@ public String extract(REQUEST request) { if (rawQueryTexts.isEmpty()) { if (emitStableDatabaseSemconv()) { + if (isBatch(request)) { + return "BATCH"; + } return computeSpanNameStable(getter, request, null, null, null); } String dbName = getter.getDbName(request); @@ -240,7 +243,7 @@ public String extract(REQUEST request) { private boolean isBatch(REQUEST request) { Long batchSize = getter.getDbOperationBatchSize(request); - return batchSize != null && batchSize > 1; + return batchSize != null && batchSize != 1; } } diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java index ecfee247671c..fbe2323ccff5 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractor.java @@ -101,10 +101,10 @@ public void onStart(AttributesBuilder attributes, Context parentContext, REQUEST SqlDialect dialect = getter.getSqlDialect(request); Long batchSize = getter.getDbOperationBatchSize(request); - boolean isBatch = batchSize != null && batchSize > 1; // db.operation.batch.size is captured for every batch execution (including an empty batch with // size 0); it is only omitted for a single-statement batch, which is reported as a non-batch boolean emitBatchSize = batchSize != null && batchSize != 1; + boolean isBatch = emitBatchSize; if (emitOldDatabaseSemconv()) { if (rawQueryTexts.size() == 1) { // for backcompat(?) diff --git a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java index 99dd151dbea8..f11a8b31eec4 100644 --- a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java +++ b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java @@ -266,6 +266,30 @@ void shouldExtractFullSpanNameForSingleQueryBatch() { .isEqualTo(emitStableDatabaseSemconv() ? "BATCH INSERT table" : "INSERT database.table"); } + @Test + void shouldExtractFullSpanNameForSingleQueryEmptyBatch() { + // given + DbRequest dbRequest = new DbRequest(); + + when(sqlAttributesGetter.getRawQueryTexts(dbRequest)) + .thenReturn(singleton("INSERT INTO table VALUES(?)")); + if (emitOldDatabaseSemconv() && !emitStableDatabaseSemconv()) { + when(sqlAttributesGetter.getDbName(dbRequest)).thenReturn("database"); + } + if (emitStableDatabaseSemconv()) { + when(sqlAttributesGetter.getDbOperationBatchSize(dbRequest)).thenReturn(0L); + } + + SpanNameExtractor underTest = DbClientSpanNameExtractor.create(sqlAttributesGetter); + + // when + String spanName = underTest.extract(dbRequest); + + // then + assertThat(spanName) + .isEqualTo(emitStableDatabaseSemconv() ? "BATCH INSERT table" : "INSERT database.table"); + } + @Test void shouldFallBackToNamespaceForEmptySqlQuery() { // given @@ -288,6 +312,28 @@ void shouldFallBackToNamespaceForEmptySqlQuery() { assertThat(spanName).isEqualTo("mydb"); } + @Test + void shouldExtractBatchSpanNameForEmptySqlQueryBatch() { + // given + DbRequest dbRequest = new DbRequest(); + + when(sqlAttributesGetter.getRawQueryTexts(dbRequest)).thenReturn(emptyList()); + if (emitStableDatabaseSemconv()) { + when(sqlAttributesGetter.getDbOperationBatchSize(dbRequest)).thenReturn(0L); + } + if (emitOldDatabaseSemconv() && !emitStableDatabaseSemconv()) { + when(sqlAttributesGetter.getDbName(dbRequest)).thenReturn("mydb"); + } + + SpanNameExtractor underTest = DbClientSpanNameExtractor.create(sqlAttributesGetter); + + // when + String spanName = underTest.extract(dbRequest); + + // then + assertThat(spanName).isEqualTo(emitStableDatabaseSemconv() ? "BATCH" : "mydb"); + } + @Test @SuppressWarnings("deprecation") // testing deprecated method void shouldPreserveOldSemconvSpanNameForMigration() { @@ -335,5 +381,29 @@ void shouldFallBackToNamespaceForEmptySqlQueryInMigration() { assertThat(spanName).isEqualTo("mydb"); } + @Test + @SuppressWarnings("deprecation") // testing deprecated method + void shouldExtractBatchSpanNameForEmptySqlQueryBatchInMigration() { + // given + DbRequest dbRequest = new DbRequest(); + + when(sqlAttributesGetter.getRawQueryTexts(dbRequest)).thenReturn(emptyList()); + if (emitStableDatabaseSemconv()) { + when(sqlAttributesGetter.getDbOperationBatchSize(dbRequest)).thenReturn(0L); + } + if (emitOldDatabaseSemconv() && !emitStableDatabaseSemconv()) { + when(sqlAttributesGetter.getDbName(dbRequest)).thenReturn("mydb"); + } + + SpanNameExtractor underTest = + DbClientSpanNameExtractor.createWithGenericOldSpanName(sqlAttributesGetter); + + // when + String spanName = underTest.extract(dbRequest); + + // then + assertThat(spanName).isEqualTo(emitStableDatabaseSemconv() ? "BATCH" : "mydb"); + } + static class DbRequest {} } diff --git a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java index 307ee95dd976..77d3520878ef 100644 --- a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java +++ b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/SqlClientAttributesExtractorTest.java @@ -320,6 +320,57 @@ void shouldExtractSingleQueryBatchAttributes() { assertThat(endAttributes.build().isEmpty()).isTrue(); } + @Test + void shouldExtractSingleQueryEmptyBatchAttributes() { + // given + Map request = new HashMap<>(); + request.put("db.namespace", "potatoes"); + request.put("db.query.texts", singleton("INSERT INTO potato VALUES(?)")); + request.put(DB_OPERATION_BATCH_SIZE.getKey(), 0L); + + Context context = Context.root(); + + AttributesExtractor, Void> underTest = + SqlClientAttributesExtractor.create(new TestMultiAttributesGetter()); + + // when + AttributesBuilder startAttributes = Attributes.builder(); + underTest.onStart(startAttributes, context, request); + + AttributesBuilder endAttributes = Attributes.builder(); + underTest.onEnd(endAttributes, context, request, null, null); + + // then + if (emitStableDatabaseSemconv() && emitOldDatabaseSemconv()) { + assertThat(startAttributes.build()) + .containsOnly( + entry(DB_NAME, "potatoes"), + entry(DB_STATEMENT, "INSERT INTO potato VALUES(?)"), + entry(DB_OPERATION, "INSERT"), + entry(DB_SQL_TABLE, "potato"), + entry(DB_NAMESPACE, "potatoes"), + entry(DB_QUERY_TEXT, "INSERT INTO potato VALUES(?)"), + entry(DB_QUERY_SUMMARY, "BATCH INSERT potato"), + entry(DB_OPERATION_BATCH_SIZE, 0L)); + } else if (emitOldDatabaseSemconv()) { + assertThat(startAttributes.build()) + .containsOnly( + entry(DB_NAME, "potatoes"), + entry(DB_STATEMENT, "INSERT INTO potato VALUES(?)"), + entry(DB_OPERATION, "INSERT"), + entry(DB_SQL_TABLE, "potato")); + } else if (emitStableDatabaseSemconv()) { + assertThat(startAttributes.build()) + .containsOnly( + entry(DB_NAMESPACE, "potatoes"), + entry(DB_QUERY_TEXT, "INSERT INTO potato VALUES(?)"), + entry(DB_QUERY_SUMMARY, "BATCH INSERT potato"), + entry(DB_OPERATION_BATCH_SIZE, 0L)); + } + + assertThat(endAttributes.build().isEmpty()).isTrue(); + } + @Test void shouldExtractMultiQueryBatchAttributes() { // given diff --git a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java index 1857370b73e5..3f6d75d24064 100644 --- a/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java +++ b/instrumentation/cassandra/cassandra-3.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/cassandra/v3_0/CassandraClientTest.java @@ -368,10 +368,10 @@ void batchStatement(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( // an empty batch still produces a client span carrying db.operation.batch.size 0, but with - // no query text, summary or operation; the span name falls back to the database system name + // no query text, summary or operation; the stable span name is BATCH BatchScenario.builder("empty") .buildBatch(session -> new BatchStatement()) - .spanName("cassandra") + .spanName("BATCH") .oldSpanName("DB Query") .batchSize(0) .build(), diff --git a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java index cb4ff3554ea0..684877ac68e9 100644 --- a/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java +++ b/instrumentation/cassandra/cassandra-common-4.0/testing/src/main/java/io/opentelemetry/cassandra/common/v4_0/AbstractCassandraTest.java @@ -277,10 +277,10 @@ void batchStatement(BatchScenario scenario) { private static Stream batchScenarios() { return Stream.of( // an empty batch still produces a client span carrying db.operation.batch.size 0, but with - // no query text, summary or operation; the span name falls back to the database system name + // no query text, summary or operation; the stable span name is BATCH BatchScenario.builder("empty") .buildBatch(session -> BatchStatement.newInstance(DefaultBatchType.LOGGED)) - .spanName("cassandra") + .spanName("BATCH") .oldSpanName("DB Query") .batchSize(0) .build(), diff --git a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java index 81d08010901d..bdba8da5160d 100644 --- a/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java +++ b/instrumentation/jdbc/testing/src/main/java/io/opentelemetry/instrumentation/jdbc/testing/AbstractJdbcInstrumentationTest.java @@ -1886,11 +1886,11 @@ void testProxyPreparedStatement() throws SQLException { // is enough to lock down its shape static Stream batchCasesStream() { return Stream.of( - // an empty batch still produces a client span carrying db.operation.batch.size 0, but no - // query text; the span name falls back to the database namespace in both modes + // an empty batch still produces a client span carrying db.operation.batch.size 0 and the + // BATCH span name under stable semconv, but no query text BatchScenario.builder() .name("empty") - .spanName(DATABASE_NAME_LOWER) + .spanName("BATCH") .oldSpanName(DATABASE_NAME_LOWER) .batchSize(0) .build(), diff --git a/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java index c9e6622659c9..097cbd60e41a 100644 --- a/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java +++ b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java @@ -22,13 +22,16 @@ class LettuceAsyncCommandsInstrumentation implements TypeInstrumentation { @Override public ElementMatcher typeMatcher() { - return named("io.lettuce.core.AbstractRedisAsyncCommands"); + return named("io.lettuce.core.AbstractRedisAsyncCommands") + .or(named("io.lettuce.core.protocol.DefaultEndpoint")); } @Override public void transform(TypeTransformer transformer) { transformer.applyAdviceToMethod( - named("dispatch").and(takesArgument(0, named("io.lettuce.core.protocol.RedisCommand"))), + named("dispatch") + .or(named("write")) + .and(takesArgument(0, named("io.lettuce.core.protocol.RedisCommand"))), getClass().getName() + "$DispatchAdvice"); transformer.applyAdviceToMethod( named("setAutoFlushCommands").and(takesArguments(1)), diff --git a/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceAsyncClientTest.java b/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceAsyncClientTest.java index cf3e2284b33a..fdc229632c82 100644 --- a/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceAsyncClientTest.java +++ b/instrumentation/lettuce/lettuce-5.1/testing/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/AbstractLettuceAsyncClientTest.java @@ -396,11 +396,13 @@ NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), void deferredFlushCommand( String name, AsyncCommandsScenario scenario, List expectedCommands) throws Exception { - asyncCommands.setAutoFlushCommands(false); - cleanup.deferCleanup(() -> asyncCommands.setAutoFlushCommands(true)); + StatefulRedisConnection statefulConnection = + asyncCommands.getStatefulConnection(); + statefulConnection.setAutoFlushCommands(false); + cleanup.deferCleanup(() -> statefulConnection.setAutoFlushCommands(true)); List> futures = scenario.run(asyncCommands); - asyncCommands.flushCommands(); + statefulConnection.flushCommands(); for (RedisFuture future : futures) { future.get(10, SECONDS); } diff --git a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java index ebfe45c51db7..9e7a14dbef65 100644 --- a/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java +++ b/instrumentation/r2dbc-1.0/testing/src/main/java/io/opentelemetry/instrumentation/r2dbc/v1_0/AbstractR2dbcStatementTest.java @@ -361,7 +361,7 @@ void batchQueries(BatchScenario scenario) { if (scenario.queries.isEmpty()) { // an empty batch fails to execute and produces a client span with no operation or summary, - // but carries db.operation.batch.size 0; the span name falls back to the database namespace. + // but carries db.operation.batch.size 0 and the BATCH span name under stable semconv. // under old semconv it still carries db.user/db.connection_string and an empty db.statement; // under stable semconv it records the error instead (error.type is stable-only) assertThat(thrown).isInstanceOf(NoSuchElementException.class); @@ -371,7 +371,7 @@ void batchQueries(BatchScenario scenario) { trace.hasSpansSatisfyingExactly( span -> span.hasName("parent").hasKind(SpanKind.INTERNAL), span -> - span.hasName(DB) + span.hasName(emitStableDatabaseSemconv() ? "BATCH" : DB) .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java index 695290b52438..e8dcf9bac707 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v4_0/VertxSqlClientTest.java @@ -368,8 +368,8 @@ private static Stream batchScenarios() { // records the error and carries db.operation.batch.size 0 BatchScenario.builder("empty") .tuples(emptyList()) - .stableSpanName("insert batch_test") - .stableSummary("insert batch_test") + .stableSpanName("BATCH insert batch_test") + .stableSummary("BATCH insert batch_test") .errorType("io.vertx.core.impl.NoStackTraceThrowable") .batchSize(0) .build(), diff --git a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java index 9f9dd710f7ee..18ed4798ca5c 100644 --- a/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java +++ b/instrumentation/vertx/vertx-sql-client/vertx-sql-client-5.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/sqlclient/v5_0/VertxSqlClientTest.java @@ -369,8 +369,8 @@ private static Stream batchScenarios() { // records the error and carries db.operation.batch.size 0 BatchScenario.builder("empty") .tuples(emptyList()) - .stableSpanName("insert batch_test") - .stableSummary("insert batch_test") + .stableSpanName("BATCH insert batch_test") + .stableSummary("BATCH insert batch_test") .errorType("io.vertx.core.VertxException") .batchSize(0) .build(), From b47927bdd7760e4027babf46cf1dd96076dfe57d Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Thu, 18 Jun 2026 05:30:55 -0700 Subject: [PATCH 29/31] Fix batch CI regressions --- .../incubator/semconv/db/DbClientSpanNameExtractorTest.java | 1 + .../instrumentation/lettuce/v5_1/LettuceRequest.java | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java index f11a8b31eec4..bebb1b12039b 100644 --- a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java +++ b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java @@ -35,6 +35,7 @@ void setUp() { lenient() .when(sqlAttributesGetter.getSqlDialect(any())) .thenReturn(DOUBLE_QUOTES_ARE_STRING_LITERALS); + lenient().when(sqlAttributesGetter.getDbOperationBatchSize(any())).thenReturn(null); } @Test diff --git a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceRequest.java b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceRequest.java index 3504283e2dc9..5b15587bf9ad 100644 --- a/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceRequest.java +++ b/instrumentation/lettuce/lettuce-5.1/library/src/main/java/io/opentelemetry/instrumentation/lettuce/v5_1/LettuceRequest.java @@ -96,12 +96,12 @@ String getStatement() { } private static String pipelineOperationName(List> commands) { - String operationName = commands.get(0).getType().name(); + String operationName = commands.get(0).getType().toString(); if (commands.size() == 1) { return operationName; } for (int i = 1; i < commands.size(); i++) { - if (!operationName.equals(commands.get(i).getType().name())) { + if (!operationName.equals(commands.get(i).getType().toString())) { return "PIPELINE"; } } @@ -111,7 +111,7 @@ private static String pipelineOperationName(List> commands private String pipelineStatement(List> commands) { StringJoiner joiner = new StringJoiner(";"); for (RedisCommand command : commands) { - String commandName = command.getType().name(); + String commandName = command.getType().toString(); List args = command.getArgs() == null ? emptyList() From dd772c94dfda09f729f58ff307bd1520940cd385 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Thu, 18 Jun 2026 05:50:39 -0700 Subject: [PATCH 30/31] Stabilize Vert.x Redis latest deps batch test --- .../vertx/redisclient/v4_0/VertxRedisClientTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientTest.java b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientTest.java index b53f8f075f89..925e9ef4c2ed 100644 --- a/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientTest.java +++ b/instrumentation/vertx/vertx-redis-client-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/vertx/redisclient/v4_0/VertxRedisClientTest.java @@ -24,6 +24,7 @@ import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_SYSTEM; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DbSystemNameIncubatingValues.REDIS; import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; @@ -83,6 +84,14 @@ static void setup() throws Exception { client.connect().toCompletionStage().toCompletableFuture().get(30, SECONDS); redis = RedisAPI.api(connection); cleanup.deferAfterAll(redis::close); + + client + .batch(singletonList(Request.cmd(Command.PING))) + .toCompletionStage() + .toCompletableFuture() + .get(30, SECONDS); + testing.waitForTraces(1); + testing.clearData(); } @Test From bd30ea525a477a40e88c329fb24ad183e62463ae Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Thu, 18 Jun 2026 06:42:06 -0700 Subject: [PATCH 31/31] Stabilize Lettuce 4 cancellation test --- .../instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/lettuce/lettuce-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java b/instrumentation/lettuce/lettuce-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java index d925fa692d8b..ffe09b4911d4 100644 --- a/instrumentation/lettuce/lettuce-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java +++ b/instrumentation/lettuce/lettuce-4.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/lettuce/v4_0/LettuceAsyncClientTest.java @@ -462,7 +462,7 @@ void testCommandBeforeItFinished() { await().untilAsserted(() -> assertThat(cancelSuccess).isTrue()); testing.waitAndAssertTraces( trace -> - trace.hasSpansSatisfyingExactly( + trace.hasSpansSatisfyingExactlyInAnyOrder( span -> span.hasName("parent") .hasKind(SpanKind.INTERNAL)