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..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,7 +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( @@ -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/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/MultiQuery.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/MultiQuery.java index 29479d915f1b..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 @@ -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,28 @@ 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..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,7 +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(?) @@ -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) { @@ -149,6 +152,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/DbClientSpanNameExtractorTest.java b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbClientSpanNameExtractorTest.java index 99dd151dbea8..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 @@ -266,6 +267,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 +313,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 +382,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 20723c66bc49..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 @@ -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; @@ -318,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 @@ -412,6 +465,66 @@ 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/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..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 @@ -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,9 +55,13 @@ 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)); - if (isBatch(batchSize)) { + attributes.put(DB_OPERATION_NAME, getStableOperationName(operation, batchSize, writeOpType)); + if (shouldEmitBatchSize(batchSize)) { attributes.put(DB_OPERATION_BATCH_SIZE, batchSize); } } @@ -96,30 +106,31 @@ private static String getSingleCollectionName(Map requestItems) { @Nullable private static String getStableOperationName( - @Nullable String operation, @Nullable Long batchSize) { - if ("BatchGetItem".equals(operation)) { - return getStableBatchOperationName(batchSize, "GetItem", operation); - } + @Nullable String operation, @Nullable Long batchSize, int writeOpType) { if ("BatchWriteItem".equals(operation)) { - return getStableBatchOperationName(batchSize, "WriteItem", operation); + return getStableWriteOperationName(batchSize, writeOpType); } return operation; } - private static String getStableBatchOperationName( - @Nullable Long batchSize, String itemOperation, String batchOperation) { + private static String getStableWriteOperationName(@Nullable Long batchSize, int writeOpType) { if (batchSize == null || batchSize == 0) { - return batchOperation; + return "BatchWriteItem"; } + String itemOp = writeOpType == WRITE_OP_PUT ? "PutItem" : "DeleteItem"; if (batchSize == 1) { - return itemOperation; + return itemOp; + } + // mixed operations collapse to bare BATCH (consistent with SQL/Cassandra) + if (writeOpType == WRITE_OP_MIXED) { + return "BATCH"; } - return "BATCH " + itemOperation; + return "BATCH " + itemOp; } @Nullable private static Long extractBatchSize(@Nullable String operation, Object request) { - if (!"BatchGetItem".equals(operation) && !"BatchWriteItem".equals(operation)) { + if (!"BatchWriteItem".equals(operation)) { return null; } @@ -128,36 +139,66 @@ private static Long extractBatchSize(@Nullable String operation, Object request) return null; } - long batchSize = - "BatchGetItem".equals(operation) - ? countBatchGetItems(requestItems) - : countBatchWriteItems(requestItems); - return batchSize == 0 ? null : batchSize; + 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) { + private static long countBatchWriteItems(Map requestItems) { long count = 0; - for (Object keysAndAttributes : requestItems.values()) { - List keys = RequestAccess.getKeys(keysAndAttributes); - if (keys != null) { - count += keys.size(); + for (Object writeRequests : requestItems.values()) { + if (writeRequests instanceof Collection) { + count += ((Collection) writeRequests).size(); } } return count; } - private static long countBatchWriteItems(Map requestItems) { - long count = 0; + /** + * 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) { - count += ((Collection) writeRequests).size(); + 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 count; + 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; } - 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/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..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; @@ -111,6 +112,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 access.invokeGetPutRequest(writeRequest) != 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 access.invokeGetDeleteRequest(writeRequest) != null; + } + @Nullable static String getSnsTopicArn(Object request) { RequestAccess access = REQUEST_ACCESSORS.get(request.getClass()); @@ -220,4 +239,53 @@ 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 Method getPutRequest; + @Nullable private final Method getDeleteRequest; + + private WriteRequestAccess(Class clz) { + 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/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..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 @@ -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; @@ -30,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; @@ -39,7 +41,12 @@ 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.MethodSource; public abstract class AbstractDynamoDbClientTest extends AbstractBaseAwsClientTest { @@ -82,10 +89,15 @@ 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 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 - @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,153 +109,220 @@ void batchGetItemWithMultipleItemsUsesStableBatchAttributes() maybeStable(DB_SYSTEM), emitStableDatabaseSemconv() ? AWS_DYNAMODB : DYNAMODB), equalTo( maybeStable(DB_OPERATION), - emitStableDatabaseSemconv() ? "BATCH GetItem" : "BatchGetItem"), + emitStableDatabaseSemconv() ? scenario.stableOperation : scenario.awsOperation), 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); + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? scenario.batchSize : null), + equalTo( + DB_COLLECTION_NAME, + emitStableDatabaseSemconv() && scenario.hasCollection ? "sometable" : null))); - assertDurationMetric( - testing(), - "io.opentelemetry.aws-sdk-1.11", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME, - SERVER_ADDRESS, - SERVER_PORT); + Object response = scenario.execute.apply(client); + assertRequestWithMockedResponse( + response, client, "DynamoDBv2", scenario.awsOperation, "POST", additionalAttributes); } - @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( - equalTo( - maybeStable(DB_SYSTEM), emitStableDatabaseSemconv() ? AWS_DYNAMODB : DYNAMODB), - 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); + private static Stream batchScenarios() { + return Stream.of( + // 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") + .build(), + BatchScenario.builder("getItemSingle") + .awsOperation("BatchGetItem") + .execute(client -> client.batchGetItem(getItemRequest(1))) + .stableOperation("BatchGetItem") + .hasCollection() + .build(), + BatchScenario.builder("getItemTwo") + .awsOperation("BatchGetItem") + .execute(client -> client.batchGetItem(getItemRequest(2))) + .stableOperation("BatchGetItem") + .hasCollection() + .build(), + BatchScenario.builder("writeItemEmpty") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(writeItemRequest(0))) + .stableOperation("BatchWriteItem") + .batchSize(0) + .build(), + // a single put request is reported as PutItem + BatchScenario.builder("writeItemSinglePut") + .awsOperation("BatchWriteItem") + .execute(client -> client.batchWriteItem(putItemsRequest(1))) + .stableOperation("PutItem") + .hasCollection() + .build(), + // a single delete request is reported as DeleteItem + BatchScenario.builder("writeItemSingleDelete") + .awsOperation("BatchWriteItem") + .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(), + // 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") + .batchSize(2) + .hasCollection() + .build()); + } - assertDurationMetric( - testing(), - "io.opentelemetry.aws-sdk-1.11", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME, - SERVER_ADDRESS, - SERVER_PORT); + 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))); } - @SuppressWarnings("deprecation") // using deprecated semconv - @Test - void batchWriteItemWithMultipleItemsUsesStableBatchAttributes() - throws ReflectiveOperationException { - AmazonDynamoDB client = createClient(); + 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(putRequest("value" + i)); + } + return new BatchWriteItemRequest().withRequestItems(singletonMap("sometable", writes)); + } - server.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "{}")); + 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)); + } - 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"))))); - assertRequestWithMockedResponse( - response, client, "DynamoDBv2", "BatchWriteItem", "POST", additionalAttributes); + 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)); + } - assertDurationMetric( - testing(), - "io.opentelemetry.aws-sdk-1.11", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME, - SERVER_ADDRESS, - SERVER_PORT); + private static WriteRequest putRequest(String value) { + return new WriteRequest() + .withPutRequest( + new PutRequest().withItem(singletonMap("key", new AttributeValue().withS(value)))); } - @SuppressWarnings("deprecation") // using deprecated semconv - @Test - void batchWriteItemWithSingleItemUsesStableItemOperation() throws ReflectiveOperationException { - AmazonDynamoDB client = createClient(); + private static WriteRequest deleteRequest(String value) { + return new WriteRequest() + .withDeleteRequest( + new DeleteRequest().withKey(singletonMap("key", new AttributeValue().withS(value)))); + } - server.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "{}")); + 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)); + } - 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 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; + } - Object response = - client.batchWriteItem( - new BatchWriteItemRequest() - .withRequestItems(singletonMap("sometable", singletonList(writeRequest("value"))))); - assertRequestWithMockedResponse( - response, client, "DynamoDBv2", "BatchWriteItem", "POST", additionalAttributes); + @Override + public String toString() { + // used as the parameterized test display name + return name; + } - assertDurationMetric( - testing(), - "io.opentelemetry.aws-sdk-1.11", - DB_SYSTEM_NAME, - DB_OPERATION_NAME, - DB_COLLECTION_NAME, - SERVER_ADDRESS, - SERVER_PORT); - } + static Builder builder(String name) { + return new Builder(name); + } - private static WriteRequest writeRequest(String value) { - return new WriteRequest() - .withPutRequest( - new PutRequest().withItem(singletonMap("key", new AttributeValue().withS(value)))); + 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() { 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..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 @@ -35,6 +35,12 @@ class DynamoDbAttributesExtractor implements AttributesExtractor requestItemsMap = (Map) requestItems.get(); - return "BatchGetItem".equals(operation) - ? countBatchGetItems(requestItemsMap) - : countBatchWriteItems(requestItemsMap); + return countBatchWriteItems(requestItemsMap); } - private long countBatchGetItems(Map requestItems) { + private static long countBatchWriteItems(Map requestItems) { long count = 0; - for (Object keysAndAttributes : requestItems.values()) { - Object keys = next(keysAndAttributes, "Keys"); - if (keys instanceof Collection) { - count += ((Collection) keys).size(); + for (Object writeRequests : requestItems.values()) { + if (writeRequests instanceof Collection) { + count += ((Collection) writeRequests).size(); } } return count; } - private static long countBatchWriteItems(Map requestItems) { - long count = 0; - for (Object writeRequests : requestItems.values()) { + /** + * 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) { - count += ((Collection) writeRequests).size(); + 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 count; + 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; } - 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 f17fabef54ca..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 @@ -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; @@ -172,7 +173,6 @@ private void validateOperationResponse(String operation, Object response) { case "ListTables": assertListTablesRequest(span); return; - case "BatchGetItem": case "GetItem": assertDynamoDbRequest( span, @@ -183,19 +183,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 +281,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 +296,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 +408,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 @@ -566,9 +492,10 @@ void testBatchGetItemWithMultipleTablesOmitsDbCollectionName() { .doesNotContainKey(DB_COLLECTION_NAME)))); } - @Test + @ParameterizedTest + @MethodSource("batchScenarios") @SuppressWarnings("deprecation") // uses deprecated semconv - void testBatchGetItemWithMultipleItemsUsesStableBatchAttributes() { + void batchOperation(BatchScenario scenario) { DynamoDbClientBuilder builder = DynamoDbClient.builder(); configureSdkClient(builder); DynamoDbClient client = @@ -578,130 +505,310 @@ 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"))); - - 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())))); - - getTesting() - .waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> - assertDynamoDbRequest( - span, - "BatchWriteItem", - asList( - equalTo( - AWS_DYNAMODB_CONSUMED_CAPACITY, + private static Stream batchScenarios() { + return Stream.of( + // 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") + .build(), + 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("BatchGetItem") + .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("BatchGetItem") + .hasCollection() + .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") + .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("writeItemSinglePut") + .awsOperation("BatchWriteItem") + .responseContent(getResponseContent("BatchWriteItem")) + .execute( + c -> + c.batchWriteItem( + b -> + b.requestItems( + ImmutableMap.of( + "sometable", 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); - } - - 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; - } + WriteRequest.builder() + .putRequest( + PutRequest.builder() + .item( + ImmutableMap.of( + "key", + AttributeValue.builder() + .s("value") + .build())) + .build()) + .build()))))) + .stableOperation("PutItem") + .hasCollection() + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() + .build(), + // 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( + 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 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 collapses to bare "BATCH" + // (consistent with SQL/Cassandra mixed-operation batches) + 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") + .hasCollection() + .batchSize(2) + .consumedCapacity("{\"TableName\":\"sometable\",\"CapacityUnits\":1.0}") + .itemCollectionMetrics("[somekey1:[{\"ItemCollectionKey\":{\"somekey2\":{}}}]]") + .assertMetric() + .build()); } private static String getResponseContent(String operation) { @@ -727,4 +834,122 @@ private static String getResponseContent(String operation) { return ""; } } + + 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; + } + + @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); + } + } + } } 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..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 @@ -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; @@ -62,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(); @@ -295,34 +300,34 @@ void testMetrics() { SERVER_PORT); } - @Test - void batchStatementWithSameQuery() { + // 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) { Session session = cluster.connect(); cleanup.deferCleanup(session); - session.execute("DROP KEYSPACE IF EXISTS batch_same_test"); + session.execute("DROP KEYSPACE IF EXISTS " + BATCH_KEYSPACE); 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 KEYSPACE " + + BATCH_KEYSPACE + + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); + session.execute("CREATE TABLE " + BATCH_KEYSPACE + ".records ( id int PRIMARY KEY, num 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") + emitStableDatabaseSemconv() ? scenario.spanName : scenario.oldSpanName) .hasKind(SpanKind.CLIENT) .hasNoParent() .hasAttributesSatisfyingExactly( @@ -335,62 +340,96 @@ void batchStatementWithSameQuery() { equalTo( maybeStable(DB_STATEMENT), emitStableDatabaseSemconv() - ? "INSERT INTO batch_same_test.users (name, age) values (?, ?)" - : null), + ? scenario.statement + : scenario.oldStatement), 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)))); - } - - @Test - void batchStatementWithDifferentQueries() { - Session session = cluster.connect(); - cleanup.deferCleanup(session); - - 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(); - - 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); - - 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), + emitStableDatabaseSemconv() ? scenario.summary : null), + // 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_STATEMENT), + maybeStable(DB_OPERATION), emitStableDatabaseSemconv() - ? "INSERT INTO batch_mixed_test.users (name, age) values ('alice', ?); UPDATE batch_mixed_test.users SET age = ? WHERE name = ?" - : null), + ? scenario.operation + : scenario.oldOperation), equalTo( - DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), - equalTo( - DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? "BATCH" : null)))); + maybeStable(DB_CASSANDRA_TABLE), + emitStableDatabaseSemconv() + ? scenario.collection + : scenario.oldCollection)))); + } + + 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 stable span name is BATCH + BatchScenario.builder("empty") + .buildBatch(session -> new BatchStatement()) + .spanName("BATCH") + .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 + // db.operation.batch.size + BatchScenario.builder("single") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare("INSERT INTO batch_test.records (id, num) values (?, ?)"); + return new BatchStatement().add(insert.bind(1, 1)); + }) + .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_test.records") + .oldCollection("batch_test.records") + .build(), + BatchScenario.builder("twoSameOperation") + .buildBatch( + session -> { + PreparedStatement insert = + 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_test.records") + .oldSpanName("DB Query") + .statement("INSERT INTO batch_test.records (id, num) values (?, ?)") + .summary("BATCH INSERT batch_test.records") + .operation("BATCH INSERT") + .collection("batch_test.records") + .batchSize(2) + .build(), + BatchScenario.builder("twoDifferentOperations") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare("INSERT INTO batch_test.records (id, num) values (4, ?)"); + return new BatchStatement() + .add(insert.bind(4)) + .add( + new SimpleStatement( + "UPDATE batch_test.records SET num = 5 WHERE id = 4")); + }) + .spanName("BATCH") + .oldSpanName("DB Query") + .statement( + "INSERT INTO batch_test.records (id, num) values (4, ?); UPDATE batch_test.records SET num = ? WHERE id = ?") + .summary("BATCH") + .operation("BATCH") + .collection("batch_test.records") + .batchSize(2) + .build()); } private static Stream provideSyncParameters() { @@ -524,4 +563,122 @@ private static class Parameter { this.table = table; } } + + private static final class BatchScenario { + final String name; + 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.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 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 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 9964fb8e8530..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 @@ -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; @@ -70,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; @@ -186,26 +191,27 @@ void simpleStatementWithValues() { maybeStable(DB_CASSANDRA_TABLE), "simple_values_test.users")))); } - @Test - void batchStatementWithSameQuery() { + // 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) { CqlSession session = getSession(null); cleanup.deferCleanup(session); - session.execute("DROP KEYSPACE IF EXISTS batch_same_test"); + session.execute("DROP KEYSPACE IF EXISTS " + BATCH_KEYSPACE); 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 KEYSPACE " + + BATCH_KEYSPACE + + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':1}"); + session.execute("CREATE TABLE " + BATCH_KEYSPACE + ".records ( id int PRIMARY KEY, num 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( @@ -214,8 +220,8 @@ void batchStatementWithSameQuery() { span -> span.hasName( emitStableDatabaseSemconv() - ? "BATCH INSERT batch_same_test.users" - : "DB Query") + ? scenario.spanName + : scenario.oldSpanName) .hasKind(SpanKind.CLIENT) .hasNoParent() .hasAttributesSatisfyingExactly( @@ -232,80 +238,29 @@ void batchStatementWithSameQuery() { equalTo( maybeStable(DB_STATEMENT), emitStableDatabaseSemconv() - ? "INSERT INTO batch_same_test.users (name, age) values (?, ?)" - : null), + ? scenario.statement + : scenario.oldStatement), equalTo( DB_OPERATION_BATCH_SIZE, - emitStableDatabaseSemconv() ? 2L : null), + emitStableDatabaseSemconv() ? scenario.batchSize : null), equalTo( DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() - ? "BATCH INSERT batch_same_test.users" - : 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), + emitStableDatabaseSemconv() ? scenario.summary : null), + // 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_CASSANDRA_SPECULATIVE_EXECUTION_COUNT), 0)))); - } - - @Test - void batchStatementWithDifferentQueries() { - CqlSession session = getSession(null); - cleanup.deferCleanup(session); - - 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(); - - 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); - - testing() - .waitAndAssertTraces( - trace -> - trace.hasSpansSatisfyingExactly( - span -> - span.hasName(emitStableDatabaseSemconv() ? "BATCH" : "DB Query") - .hasKind(SpanKind.CLIENT) - .hasNoParent() - .hasAttributesSatisfyingExactly( - satisfies( - NETWORK_TYPE, + maybeStable(DB_OPERATION), 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), + ? scenario.operation + : scenario.oldOperation), equalTo( - maybeStable(DB_STATEMENT), + maybeStable(DB_CASSANDRA_TABLE), 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), + ? scenario.collection + : scenario.oldCollection), equalTo(maybeStable(DB_CASSANDRA_CONSISTENCY_LEVEL), "LOCAL_ONE"), equalTo(maybeStable(DB_CASSANDRA_COORDINATOR_DC), "datacenter1"), satisfies( @@ -319,6 +274,74 @@ DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? "BATCH" : null), maybeStable(DB_CASSANDRA_SPECULATIVE_EXECUTION_COUNT), 0)))); } + 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 stable span name is BATCH + BatchScenario.builder("empty") + .buildBatch(session -> BatchStatement.newInstance(DefaultBatchType.LOGGED)) + .spanName("BATCH") + .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 + // db.operation.batch.size + BatchScenario.builder("single") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare("INSERT INTO batch_test.records (id, num) values (?, ?)"); + return BatchStatement.newInstance(DefaultBatchType.LOGGED, insert.bind(1, 1)); + }) + .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_test.records") + .oldCollection("batch_test.records") + .build(), + BatchScenario.builder("twoSameOperation") + .buildBatch( + session -> { + PreparedStatement insert = + 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_test.records") + .oldSpanName("DB Query") + .statement("INSERT INTO batch_test.records (id, num) values (?, ?)") + .summary("BATCH INSERT batch_test.records") + .operation("BATCH INSERT") + .collection("batch_test.records") + .batchSize(2) + .build(), + BatchScenario.builder("twoDifferentOperations") + .buildBatch( + session -> { + PreparedStatement insert = + session.prepare("INSERT INTO batch_test.records (id, num) values (4, ?)"); + return BatchStatement.newInstance( + DefaultBatchType.LOGGED, + insert.bind(4), + SimpleStatement.newInstance( + "UPDATE batch_test.records SET num = 5 WHERE id = 4")); + }) + .spanName("BATCH") + .oldSpanName("DB Query") + .statement( + "INSERT INTO batch_test.records (id, num) values (4, ?); UPDATE batch_test.records SET num = ? WHERE id = ?") + .summary("BATCH") + .operation("BATCH") + .collection("batch_test.records") + .batchSize(2) + .build()); + } + @ParameterizedTest(name = "{index}: {0}") @MethodSource("provideSyncParameters") void syncTest(Parameter parameter) { @@ -574,4 +597,122 @@ protected CqlSessionBuilder addContactPoint(CqlSessionBuilder sessionBuilder) { sessionBuilder.addContactPoint(new InetSocketAddress(cassandra.getHost(), cassandraPort)); return sessionBuilder; } + + private static final class BatchScenario { + final String name; + 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.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 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 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/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/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..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 @@ -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; @@ -76,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) { @@ -83,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, null); + 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 5d5f71a5d426..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 @@ -1879,82 +1879,94 @@ void testProxyPreparedStatement() throws SQLException { .hasParent(trace.getSpan(0)))); } - static Stream batchStream() 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 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( - Arguments.of("h2", new org.h2.Driver().connect(JDBC_URLS.get("h2"), null), null, "h2:mem:"), - Arguments.of( - "derby", - new EmbeddedDriver().connect(JDBC_URLS.get("derby"), null), - "APP", - "derby:memory:"), - Arguments.of( - "hsqldb", new JDBCDriver().connect(JDBC_URLS.get("hsqldb"), null), "SA", "hsqldb:mem:"), - Arguments.of( - "sqlite", - new JDBC().connect(JDBC_URLS.get("sqlite"), new Properties()), - null, - "sqlite:memory:")); + // 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("BATCH") + .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 + BatchScenario.builder() + .name("single") + .addQuery("INSERT INTO batch_test (id, num) VALUES (1, 1)") + .expectedResult(1) + .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("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 batch_test (id, num) VALUES (1, 1)") + .addQuery("INSERT INTO batch_test (id, num) VALUES (2, 2)") + .expectedResult(1, 1) + .spanName("BATCH INSERT batch_test") + .oldSpanName(DATABASE_NAME_LOWER) + .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, + // 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") + .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 batch_test (id, num) VALUES (?, ?); UPDATE batch_test SET num = ? WHERE id = ?") + .summary("BATCH") + .batchSize(2) + .build()); } @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) - throws SQLException { - - Statement createTable = wrap(connection).createStatement(); - cleanup.deferCleanup(createTable); - createTable.execute( - "CREATE TABLE simple_batch_test_large (id INTEGER not NULL, PRIMARY KEY ( id ))"); - Statement statement = wrap(connection).createStatement(); - cleanup.deferCleanup(statement); - statement.addBatch("INSERT INTO simple_batch_test_large VALUES(1)"); - statement.addBatch("INSERT INTO simple_batch_test_large VALUES(2)"); - - if (testLatestDeps() || "sqlite".equals(system)) { - assertThat(statement.executeLargeBatch()).isEqualTo(new long[] {1, 1}); - } else { - // Older drivers don't support JDBC 4.2, expect UnsupportedOperationException - // This is the correct behavior - instrumentation should not change driver behavior - assertThatThrownBy(statement::executeLargeBatch) - .isInstanceOf(UnsupportedOperationException.class); - } - } + @MethodSource("batchCasesStream") + void testStatementBatch(BatchScenario scenario) throws SQLException { + Connection connection = wrap(new org.h2.Driver().connect(JDBC_URLS.get("h2"), null)); + cleanup.deferCleanup(connection); - private void testBatchImpl( - String system, - Connection connection, - String username, - String url, - String tableName, - ThrowingConsumer action) - throws SQLException { + // 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 batch_test"); + cleanup.deferCleanup(dropTable); Statement createTable = connection.createStatement(); - createTable.execute("CREATE TABLE " + tableName + " (id INTEGER not NULL, PRIMARY KEY ( id ))"); + createTable.execute( + "CREATE TABLE batch_test (id INTEGER not NULL, num INTEGER, PRIMARY KEY ( id ))"); cleanup.deferCleanup(createTable); - - testing().waitForTraces(1); + testing().waitForTraces(2); 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)); + for (String sql : scenario.statementsToAdd) { + statement.addBatch(sql); + } + + testing() + .runWithSpan( + "parent", + () -> assertThat(statement.executeBatch()).isEqualTo(scenario.expectedResult)); testing() .waitAndAssertTraces( @@ -1964,137 +1976,80 @@ private void testBatchImpl( span -> span.hasName( emitStableDatabaseSemconv() - ? "BATCH INSERT " + tableName - : "jdbcunittest") + ? scenario.spanName + : scenario.oldSpanName) .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( - equalTo(maybeStable(DB_SYSTEM), maybeStableDbSystemName(system)), + equalTo(maybeStable(DB_SYSTEM), maybeStableDbSystemName("h2")), equalTo(maybeStable(DB_NAME), DATABASE_NAME_LOWER), - equalTo(DB_USER, emitStableDatabaseSemconv() ? null : username), equalTo( - DB_CONNECTION_STRING, emitStableDatabaseSemconv() ? null : url), + 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() - ? "INSERT INTO " + tableName + " VALUES(?)" - : null), + ? scenario.queryText + : scenario.oldStatement), equalTo( DB_QUERY_SUMMARY, - emitStableDatabaseSemconv() - ? "BATCH INSERT " + tableName - : null), + emitStableDatabaseSemconv() ? scenario.summary : 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), + maybeStable(DB_OPERATION), + emitStableDatabaseSemconv() ? null : scenario.oldOperation), equalTo( - DB_QUERY_SUMMARY, emitStableDatabaseSemconv() ? "BATCH" : null), - equalTo(maybeStable(DB_OPERATION), null), + maybeStable(DB_SQL_TABLE), + emitStableDatabaseSemconv() ? null : scenario.oldTable), equalTo( DB_OPERATION_BATCH_SIZE, - emitStableDatabaseSemconv() ? 2L : null)))); + emitStableDatabaseSemconv() ? scenario.batchSize : null)))); + } + + static Stream batchStream() throws SQLException { + return Stream.of( + Arguments.of("h2", new org.h2.Driver().connect(JDBC_URLS.get("h2"), null), null, "h2:mem:"), + Arguments.of( + "derby", + new EmbeddedDriver().connect(JDBC_URLS.get("derby"), null), + "APP", + "derby:memory:"), + Arguments.of( + "hsqldb", new JDBCDriver().connect(JDBC_URLS.get("hsqldb"), null), "SA", "hsqldb:mem:"), + Arguments.of( + "sqlite", + new JDBC().connect(JDBC_URLS.get("sqlite"), new Properties()), + null, + "sqlite:memory:")); } @ParameterizedTest @MethodSource("batchStream") - void testSingleItemBatch(String system, Connection conn, String username, String url) + void testLargeBatch(String system, Connection connection, 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(); + Statement createTable = wrap(connection).createStatement(); + cleanup.deferCleanup(createTable); + createTable.execute( + "CREATE TABLE simple_batch_test_large (id INTEGER not NULL, PRIMARY KEY ( id ))"); + Statement statement = wrap(connection).createStatement(); cleanup.deferCleanup(statement); - statement.addBatch("INSERT INTO " + tableName + " VALUES(1)"); - testing() - .runWithSpan("parent", () -> assertThat(statement.executeBatch()).isEqualTo(new int[] {1})); + statement.addBatch("INSERT INTO simple_batch_test_large VALUES(1)"); + statement.addBatch("INSERT INTO simple_batch_test_large VALUES(2)"); - 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)))); + if (testLatestDeps() || "sqlite".equals(system)) { + assertThat(statement.executeLargeBatch()).isEqualTo(new long[] {1, 1}); + } else { + // Older drivers don't support JDBC 4.2, expect UnsupportedOperationException + // This is the correct behavior - instrumentation should not change driver behavior + assertThatThrownBy(statement::executeLargeBatch) + .isInstanceOf(UnsupportedOperationException.class); + } } @ParameterizedTest @@ -2446,4 +2401,115 @@ void testStatementWrapper() throws SQLException { .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(1)))); } + + private static final class BatchScenario { + final String name; + 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) { + this.name = builder.name; + 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; + } + + @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 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) { + this.name = name; + 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 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; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } 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..7c6c67736963 --- /dev/null +++ b/instrumentation/jedis/jedis-1.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/jedis/v1_4/JedisPipelineInstrumentation.java @@ -0,0 +1,133 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +class JedisPipelineInstrumentation implements TypeInstrumentation { + @Override + public ElementMatcher typeMatcher() { + return namedOneOf( + "redis.clients.jedis.Jedis", + "redis.clients.jedis.Pipeline", + "redis.clients.jedis.PipelineBase", + "redis.clients.jedis.MultiKeyPipelineBase"); + } + + @Override + 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") + 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(); + } + } + + @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/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..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 @@ -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,16 +19,23 @@ 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.Jedis; @@ -162,4 +170,168 @@ 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) + throws ReflectiveOperationException { + runPipeline(scenario); + + 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 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 { + 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-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/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..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 @@ -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; @@ -456,7 +462,7 @@ void testCommandBeforeItFinished() { await().untilAsserted(() -> assertThat(cancelSuccess).isTrue()); testing.waitAndAssertTraces( trace -> - trace.hasSpansSatisfyingExactly( + trace.hasSpansSatisfyingExactlyInAnyOrder( span -> span.hasName("parent") .hasKind(SpanKind.INTERNAL) @@ -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..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 @@ -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 { @@ -463,7 +469,7 @@ void testCancelCommandBeforeItFinishes() { await().untilAsserted(() -> assertThat(cancelSuccess).isTrue()); testing.waitAndAssertTraces( trace -> - trace.hasSpansSatisfyingExactly( + trace.hasSpansSatisfyingExactlyInAnyOrder( span -> span.hasName("parent") .hasKind(SpanKind.INTERNAL) @@ -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..097cbd60e41a --- /dev/null +++ b/instrumentation/lettuce/lettuce-5.1/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/lettuce/v5_1/LettuceAsyncCommandsInstrumentation.java @@ -0,0 +1,83 @@ +/* + * 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") + .or(named("io.lettuce.core.protocol.DefaultEndpoint")); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + named("dispatch") + .or(named("write")) + .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/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() {} 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..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 @@ -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().toString(); + if (commands.size() == 1) { + return operationName; + } + for (int i = 1; i < commands.size(); i++) { + if (!operationName.equals(commands.get(i).getType().toString())) { + return "PIPELINE"; + } + } + return "PIPELINE " + operationName; + } + + private String pipelineStatement(List> commands) { + StringJoiner joiner = new StringJoiner(";"); + for (RedisCommand command : commands) { + String commandName = command.getType().toString(); + 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..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 @@ -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,152 @@ NETWORK_TYPE, emitOldDatabaseSemconv() ? IPV4 : null), }); } + @ParameterizedTest(name = "{0}") + @MethodSource("deferredFlushScenarios") + void deferredFlushCommand( + String name, AsyncCommandsScenario scenario, List expectedCommands) + throws Exception { + StatefulRedisConnection statefulConnection = + asyncCommands.getStatefulConnection(); + statefulConnection.setAutoFlushCommands(false); + cleanup.deferCleanup(() -> statefulConnection.setAutoFlushCommands(true)); + + List> futures = scenario.run(asyncCommands); + statefulConnection.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/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/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/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) 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..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 @@ -14,8 +14,8 @@ 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; import static io.opentelemetry.semconv.ServerAttributes.SERVER_PORT; import static io.opentelemetry.semconv.incubating.DbIncubatingAttributes.DB_CONNECTION_STRING; @@ -32,19 +32,26 @@ import static io.r2dbc.spi.ConnectionFactoryOptions.PASSWORD; import static io.r2dbc.spi.ConnectionFactoryOptions.PORT; import static io.r2dbc.spi.ConnectionFactoryOptions.USER; -import static org.junit.jupiter.api.Assumptions.assumeTrue; +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.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.NoSuchElementException; import java.util.stream.Stream; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Test; @@ -298,10 +305,15 @@ void testMetrics() { SERVER_PORT); } - @Test - void testBatchQueries() { - assumeTrue(emitStableDatabaseSemconv()); - + // 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) { DbSystemProps props = systems.get(MARIADB.system); startContainer(props); ConnectionFactory connectionFactory = @@ -316,44 +328,198 @@ void testBatchQueries() { .option(CONNECT_TIMEOUT, Duration.ofSeconds(30)) .build()); - getTesting() - .runWithSpan( - "parent", - () -> { - 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))) - .blockLast(Duration.ofMinutes(1)); - }); + // 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(); + + 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)); + })); + + String connectionString = MARIADB.system + "://localhost:" + port; + + 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 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); + getTesting() + .waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> span.hasName("parent").hasKind(SpanKind.INTERNAL), + span -> + span.hasName(emitStableDatabaseSemconv() ? "BATCH" : DB) + .hasKind(SpanKind.CLIENT) + .hasParent(trace.getSpan(0)) + .hasAttributesSatisfyingExactly( + 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( + DB_OPERATION_BATCH_SIZE, + emitStableDatabaseSemconv() ? 0L : null), + equalTo(maybeStablePeerService(), "test-peer-service"), + equalTo(SERVER_ADDRESS, container.getHost()), + equalTo(SERVER_PORT, port), + equalTo( + ERROR_TYPE, + emitStableDatabaseSemconv() + ? "java.util.NoSuchElementException" + : null)))); + return; + } + assertThat(thrown).isNull(); getTesting() .waitAndAssertTraces( trace -> trace.hasSpansSatisfyingExactly( span -> span.hasName("parent").hasKind(SpanKind.INTERNAL), span -> - span.hasName("BATCH SELECT") + 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, "BATCH SELECT"), - equalTo(DB_OPERATION_BATCH_SIZE, 2), + 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), + // 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), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, container.getHost()), equalTo(SERVER_PORT, port)))); } + 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 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("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 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 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 batch_test (id, num) VALUES (?, ?); INSERT INTO batch_test (id, num) VALUES (?, ?)") + .oldOperation("INSERT") + .oldCollection("batch_test") + .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 batch_test (id, num) VALUES (1, 1)", + "UPDATE batch_test SET num = 5 WHERE id = 1")) + .spanName("BATCH") + .oldSpanName("INSERT " + DB + ".batch_test") + .summary("BATCH") + .queryText( + "INSERT INTO batch_test (id, num) VALUES (?, ?); UPDATE batch_test SET num = ? WHERE id = ?") + .oldStatement( + "INSERT INTO batch_test (id, num) VALUES (?, ?); UPDATE batch_test SET num = ? WHERE id = ?") + .oldOperation("INSERT") + .oldCollection("batch_test") + .batchSize(2) + .build()); + } + + private void recreateBatchTestTable(ConnectionFactory connectionFactory) { + Mono.from(connectionFactory.create()) + .flatMapMany( + connection -> + Mono.from(connection.createStatement("DROP TABLE IF EXISTS batch_test").execute()) + .flatMapMany(result -> result.map((row, metadata) -> "")) + .concatWith( + Mono.from( + connection + .createStatement( + "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))) + .blockLast(Duration.ofMinutes(1)); + } + private static class Parameter { private final String system; @@ -407,4 +573,109 @@ 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; + // the old-semconv db.sql.table (only captured for a single-statement batch) + final String oldCollection; + 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.oldCollection = builder.oldCollection; + 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 String oldCollection; + 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 oldCollection(String oldCollection) { + this.oldCollection = oldCollection; + return this; + } + + Builder batchSize(long batchSize) { + this.batchSize = batchSize; + return this; + } + + BatchScenario build() { + return new BatchScenario(this); + } + } + } } 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-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..d358c970856f --- /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,111 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +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; +import java.util.Map; +import java.util.WeakHashMap; +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<>()); + + 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; + } + + if (!request.isExecCommand()) { + activeSpan.request.addCommandsFrom(request); + activeSpan.updateAttributes(); + } + 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); + } + + 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 bad2c1915961..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(); } @@ -99,13 +124,37 @@ public String getOperationName() { return null; } + public boolean isMultiBatch() { + Object command = getCommand(); + if (!(command instanceof CommandsData)) { + return false; + } + List> commands = 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 : getCommands()) { + if (singleCommand.getCommand().getName().equals("EXEC")) { + return true; + } + } + } + return false; + } + @Nullable public Long getOperationBatchSize() { Object command = getCommand(); if (!(command instanceof CommandsData)) { return null; } - List> commands = ((CommandsData) command).getCommands(); + List> commands = getCommands(); if (commands.isEmpty()) { return null; } @@ -151,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); @@ -209,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 9f37859778d7..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; @@ -125,6 +126,7 @@ void setup(TestInfo testInfo) throws InvocationTargetException, IllegalAccessExc void cleanup() { if (redisson != null) { redisson.shutdown(); + testing.clearData(); } } @@ -241,7 +243,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(), @@ -256,32 +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 ?")) - .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")) + 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 abd694d9e56e..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 @@ -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,8 @@ 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.MethodSource; import org.redisson.Redisson; import org.redisson.api.BatchOptions; import org.redisson.api.RAtomicLong; @@ -142,6 +145,7 @@ void setup(TestInfo testInfo) throws InvocationTargetException, IllegalAccessExc void cleanup() { if (redisson != null) { redisson.shutdown(); + testing.clearData(); } } @@ -246,63 +250,98 @@ void stringCommand() { equalTo(maybeStable(DB_OPERATION), "GET")))); } - @Test - void batchCommand() throws ReflectiveOperationException { + // 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 { 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); + 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( span -> - span.hasName(emitStableDatabaseSemconv() ? "PIPELINE SET" : "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() ? "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( + DB_OPERATION, + emitStableDatabaseSemconv() ? null : scenario.oldOperation), + 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( + // 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()); } - @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 void invokeExecute(RBatch batch) throws ReflectiveOperationException { + batch.getClass().getMethod("execute").invoke(batch); } @Test @@ -361,8 +400,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), @@ -377,32 +415,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 ?")) - .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")) + equalTo( + DB_OPERATION_BATCH_SIZE, emitStableDatabaseSemconv() ? 2L : null), + equalTo(maybeStable(DB_STATEMENT), "MULTI;SET batch1 ?;SET batch2 ?")) .hasParent(trace.getSpan(0)))); } @@ -607,4 +622,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-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..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 @@ -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; @@ -23,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; @@ -32,14 +34,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 @@ -76,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 @@ -201,7 +217,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 +276,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 +297,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); + } + } + } } 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 b80f6d7468f6..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 @@ -29,6 +29,8 @@ 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; @@ -50,12 +52,16 @@ 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; +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.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.containers.GenericContainer; @@ -280,17 +286,32 @@ private static void assertPreparedSelect() { equalTo(SERVER_PORT, port)))); } - @Test - void testBatch() throws Exception { - testing - .runWithSpan( - "parent", - () -> - pool.preparedQuery("insert into test values ($1, $2) returning *") - .executeBatch(asList(Tuple.of(3, "Three"), Tuple.of(4, "Four")))) - .toCompletionStage() - .toCompletableFuture() - .get(30, SECONDS); + // 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 { + // 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 batch_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 -> @@ -299,8 +320,8 @@ void testBatch() throws Exception { span -> span.hasName( emitStableDatabaseSemconv() - ? "BATCH insert test" - : "INSERT tempdb.test") + ? scenario.stableSpanName + : "INSERT tempdb.batch_test") .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( @@ -311,23 +332,62 @@ void testBatch() 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() ? "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"), equalTo( maybeStable(DB_SQL_TABLE), - emitStableDatabaseSemconv() ? null : "test"), + emitStableDatabaseSemconv() ? null : "batch_test"), + equalTo( + ERROR_TYPE, + emitStableDatabaseSemconv() ? scenario.errorType : null), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, host), 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 carries db.operation.batch.size 0 + BatchScenario.builder("empty") + .tuples(emptyList()) + .stableSpanName("BATCH insert batch_test") + .stableSummary("BATCH 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 + BatchScenario.builder("single") + .tuples(singletonList(Tuple.of(1, 1))) + .stableSpanName("insert batch_test") + .stableSummary("insert batch_test") + .build(), + BatchScenario.builder("twoSameOperation") + .tuples(asList(Tuple.of(1, 1), Tuple.of(2, 2))) + .stableSpanName("BATCH insert batch_test") + .stableSummary("BATCH insert batch_test") + .batchSize(2) + .build()); + } + @Test void testWithTransaction() throws Exception { testing @@ -508,4 +568,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/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 d9b4bd36a82a..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 @@ -29,6 +29,8 @@ 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; @@ -50,12 +52,16 @@ 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; +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.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.containers.GenericContainer; @@ -281,17 +287,32 @@ private static void assertPreparedSelect() { equalTo(SERVER_PORT, port)))); } - @Test - void testBatch() throws Exception { - testing - .runWithSpan( - "parent", - () -> - pool.preparedQuery("insert into test values ($1, $2) returning *") - .executeBatch(asList(Tuple.of(3, "Three"), Tuple.of(4, "Four")))) - .toCompletionStage() - .toCompletableFuture() - .get(30, SECONDS); + // 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 { + // 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 batch_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 -> @@ -300,8 +321,8 @@ void testBatch() throws Exception { span -> span.hasName( emitStableDatabaseSemconv() - ? "BATCH insert test" - : "INSERT tempdb.test") + ? scenario.stableSpanName + : "INSERT tempdb.batch_test") .hasKind(SpanKind.CLIENT) .hasParent(trace.getSpan(0)) .hasAttributesSatisfyingExactly( @@ -312,23 +333,62 @@ void testBatch() 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() ? "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"), equalTo( maybeStable(DB_SQL_TABLE), - emitStableDatabaseSemconv() ? null : "test"), + emitStableDatabaseSemconv() ? null : "batch_test"), + equalTo( + ERROR_TYPE, + emitStableDatabaseSemconv() ? scenario.errorType : null), equalTo(maybeStablePeerService(), "test-peer-service"), equalTo(SERVER_ADDRESS, host), 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 carries db.operation.batch.size 0 + BatchScenario.builder("empty") + .tuples(emptyList()) + .stableSpanName("BATCH insert batch_test") + .stableSummary("BATCH 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 + BatchScenario.builder("single") + .tuples(singletonList(Tuple.of(1, 1))) + .stableSpanName("insert batch_test") + .stableSummary("insert batch_test") + .build(), + BatchScenario.builder("twoSameOperation") + .tuples(asList(Tuple.of(1, 1), Tuple.of(2, 2))) + .stableSpanName("BATCH insert batch_test") + .stableSummary("BATCH insert batch_test") + .batchSize(2) + .build()); + } + @Test void testWithTransaction() throws Exception { testing @@ -510,4 +570,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); + } + } + } }