diff --git a/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/SQLCommenter.java b/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/SQLCommenter.java index 075ccbbbcc7..57abf11b9c0 100644 --- a/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/SQLCommenter.java +++ b/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/SQLCommenter.java @@ -8,6 +8,7 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -117,7 +118,9 @@ public static String inject( final String parentService = config.getServiceName(); final String env = config.getEnv(); final String version = config.getVersion(); - final int commentSize = capacity(traceParent, parentService, dbService, env, version); + final Map customFields = SQLCommenterContext.getCopyOfContextMap(); + final int commentSize = + capacity(traceParent, parentService, dbService, env, version, customFields); StringBuilder sb = new StringBuilder(sql.length() + commentSize); boolean commentAdded = false; String peerService = peerServiceObj != null ? peerServiceObj.toString() : null; @@ -137,7 +140,8 @@ public static String inject( peerService, env, version, - traceParent); + traceParent, + customFields); sb.append(CLOSE_COMMENT); } else { sb.append(OPEN_COMMENT); @@ -152,7 +156,8 @@ public static String inject( peerService, env, version, - traceParent); + traceParent, + customFields); sb.append(CLOSE_COMMENT); sb.append(SPACE); @@ -226,7 +231,8 @@ protected static boolean toComment( final String peerService, final String env, final String version, - final String traceparent) { + final String traceparent, + final Map customFields) { int emptySize = sb.length(); append(sb, PARENT_SERVICE, parentService, false); @@ -241,6 +247,14 @@ protected static boolean toComment( if (injectTrace) { append(sb, TRACEPARENT, traceparent, sb.length() > emptySize); } + + // Add custom fields from SQLCommenterContext + if (customFields != null) { + for (Map.Entry entry : customFields.entrySet()) { + append(sb, encode(entry.getKey()), entry.getValue(), sb.length() > emptySize); + } + } + return sb.length() > emptySize; } @@ -268,7 +282,8 @@ private static int capacity( final String parentService, final String dbService, final String env, - final String version) { + final String version, + final Map customFields) { int len = INITIAL_CAPACITY; if (null != traceparent) { len += traceparent.length(); @@ -285,6 +300,18 @@ private static int capacity( if (null != version) { len += version.length(); } + + // Add capacity for custom fields + if (customFields != null) { + for (Map.Entry entry : customFields.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + len += entry.getKey().length(); + len += entry.getValue().length(); + len += 4; // equals, comma, and two quotes + } + } + } + return len; } diff --git a/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/SQLCommenterContext.java b/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/SQLCommenterContext.java new file mode 100644 index 00000000000..4f010c90d97 --- /dev/null +++ b/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/SQLCommenterContext.java @@ -0,0 +1,43 @@ +package datadog.trace.instrumentation.jdbc; + +import java.util.HashMap; +import java.util.Map; + +/** + * Thread-local context system for custom SQL comment fields. This class provides functionality + * similar to SLF4J's MDC but specifically for SQL comment injection. External users can add custom + * key-value pairs that will be included in SQL comments. + */ +public class SQLCommenterContext { + + private static final ThreadLocal> contextHolder = + new ThreadLocal>() { + @Override + protected Map initialValue() { + return new HashMap<>(); + } + }; + + /** + * Gets a copy of the current context map. + * + * @return a copy of the current context map, or null if no context is set + */ + public static Map getCopyOfContextMap() { + Map contextMap = contextHolder.get(); + return contextMap.isEmpty() ? null : new HashMap<>(contextMap); + } + + /** + * Sets the context map for the current thread. + * + * @param contextMap the new context map to set + */ + public static void setContextMap(Map contextMap) { + if (contextMap == null) { + contextHolder.remove(); + } else { + contextHolder.set(new HashMap<>(contextMap)); + } + } +} diff --git a/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/StatementInstrumentation.java b/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/StatementInstrumentation.java index 33b4ff4af31..cca546356da 100644 --- a/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/StatementInstrumentation.java +++ b/dd-java-agent/instrumentation/jdbc/src/main/java/datadog/trace/instrumentation/jdbc/StatementInstrumentation.java @@ -58,7 +58,11 @@ public Map contextStore() { @Override public String[] helperClassNames() { - return new String[] {packageName + ".JDBCDecorator", packageName + ".SQLCommenter"}; + return new String[] { + packageName + ".JDBCDecorator", + packageName + ".SQLCommenter", + packageName + ".SQLCommenterContext" + }; } // prepend mode will prepend the SQL comment to the raw sql query diff --git a/dd-java-agent/instrumentation/jdbc/src/test/groovy/SQLCommenterTest.groovy b/dd-java-agent/instrumentation/jdbc/src/test/groovy/SQLCommenterTest.groovy index 79afb02602c..16f234c7647 100644 --- a/dd-java-agent/instrumentation/jdbc/src/test/groovy/SQLCommenterTest.groovy +++ b/dd-java-agent/instrumentation/jdbc/src/test/groovy/SQLCommenterTest.groovy @@ -4,6 +4,7 @@ import datadog.trace.bootstrap.instrumentation.api.AgentTracer import datadog.trace.bootstrap.instrumentation.api.Tags import datadog.trace.instrumentation.jdbc.SQLCommenter +import datadog.trace.instrumentation.jdbc.SQLCommenterContext import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace @@ -145,4 +146,185 @@ class SQLCommenterTest extends AgentTestRunner { "SELECT * FROM foo" | "SqlCommenter" | "Test" | "my-service" | "mysql" | "h" | "n" | "TestVersion" | true | true | "00-00000000000000007fffffffffffffff-000000024cb016ea-00" | "testPeer" | "SELECT * FROM foo /*ddps='SqlCommenter',dddbs='my-service',ddh='h',dddb='n',ddprs='testPeer',dde='Test',ddpv='TestVersion',traceparent='00-00000000000000007fffffffffffffff-000000024cb016ea-00'*/" "SELECT * FROM foo" | "SqlCommenter" | "Test" | "my-service" | "postgres" | "h" | "n" | "TestVersion" | true | true | "00-00000000000000007fffffffffffffff-000000024cb016ea-00" | "testPeer" | "SELECT * FROM foo /*ddps='SqlCommenter',dddbs='my-service',ddh='h',dddb='n',ddprs='testPeer',dde='Test',ddpv='TestVersion',traceparent='00-00000000000000007fffffffffffffff-000000024cb016ea-00'*/" } + + def "test SQL comment injection with custom fields"() { + setup: + injectSysConfig("dd.service", "SqlCommenter") + injectSysConfig("dd.env", "Test") + injectSysConfig("dd.version", "TestVersion") + + when: + String sqlWithComment = "" + + // Set up custom fields in context + SQLCommenterContext.put("tenant_id", "abc123") + SQLCommenterContext.put("request_id", "req-456") + SQLCommenterContext.put("user_id", 789) + + sqlWithComment = SQLCommenter.inject( + "SELECT * FROM foo", + "my-service", + "mysql", + "h", + "n", + "00-00000000000000007fffffffffffffff-000000024cb016ea-00", + true, + true + ) + + SQLCommenterContext.clear() + + then: + // Should contain all standard fields plus custom fields + sqlWithComment.contains("ddps='SqlCommenter'") + sqlWithComment.contains("dddbs='my-service'") + sqlWithComment.contains("ddh='h'") + sqlWithComment.contains("dddb='n'") + sqlWithComment.contains("dde='Test'") + sqlWithComment.contains("ddpv='TestVersion'") + sqlWithComment.contains("traceparent='00-00000000000000007fffffffffffffff-000000024cb016ea-00'") + sqlWithComment.contains("tenant_id='abc123'") + sqlWithComment.contains("request_id='req-456'") + sqlWithComment.contains("user_id='789'") + sqlWithComment.startsWith("SELECT * FROM foo /*") + sqlWithComment.endsWith("*/") + } + + def "test SQL comment injection with custom fields using withSQLCommentFields"() { + setup: + injectSysConfig("dd.service", "SqlCommenter") + injectSysConfig("dd.env", "Test") + injectSysConfig("dd.version", "TestVersion") + + when: + String sqlWithComment = SQLCommenterContext.withSQLCommentFields( + ["tenant_id": "abc123", "request_id": "req-456"], { + return SQLCommenter.inject( + "SELECT * FROM foo", + "my-service", + "mysql", + "h", + "n", + "00-00000000000000007fffffffffffffff-000000024cb016ea-00", + true, + true + ) + } + ) + + then: + // Should contain all standard fields plus custom fields + sqlWithComment.contains("ddps='SqlCommenter'") + sqlWithComment.contains("dddbs='my-service'") + sqlWithComment.contains("tenant_id='abc123'") + sqlWithComment.contains("request_id='req-456'") + sqlWithComment.startsWith("SELECT * FROM foo /*") + sqlWithComment.endsWith("*/") + + // Context should be cleared after the block + SQLCommenterContext.isEmpty() + } + + def "test SQL comment injection with empty custom fields"() { + setup: + injectSysConfig("dd.service", "SqlCommenter") + injectSysConfig("dd.env", "Test") + injectSysConfig("dd.version", "TestVersion") + + when: + String sqlWithComment = SQLCommenter.inject( + "SELECT * FROM foo", + "my-service", + "mysql", + "h", + "n", + "00-00000000000000007fffffffffffffff-000000024cb016ea-00", + true, + true + ) + + then: + // Should contain only standard fields + sqlWithComment.contains("ddps='SqlCommenter'") + sqlWithComment.contains("dddbs='my-service'") + sqlWithComment.contains("ddh='h'") + sqlWithComment.contains("dddb='n'") + sqlWithComment.contains("dde='Test'") + sqlWithComment.contains("ddpv='TestVersion'") + sqlWithComment.contains("traceparent='00-00000000000000007fffffffffffffff-000000024cb016ea-00'") + sqlWithComment.startsWith("SELECT * FROM foo /*") + sqlWithComment.endsWith("*/") + } + + def "test SQL comment injection with custom fields containing special characters"() { + setup: + injectSysConfig("dd.service", "SqlCommenter") + injectSysConfig("dd.env", "Test") + injectSysConfig("dd.version", "TestVersion") + + when: + String sqlWithComment = "" + + // Set up custom fields with special characters that need URL encoding + SQLCommenterContext.put("special_key", "value with spaces") + SQLCommenterContext.put("unicode_key", "value_with_émojis_🎉") + SQLCommenterContext.put("symbols_key", "value&with=symbols") + + sqlWithComment = SQLCommenter.inject( + "SELECT * FROM foo", + "my-service", + "mysql", + "h", + "n", + null, + false, + true + ) + + SQLCommenterContext.clear() + + then: + // Should contain URL-encoded custom fields (URLEncoder uses + for spaces) + sqlWithComment.contains("special_key='value+with+spaces'") + sqlWithComment.contains("unicode_key=") // Should be URL encoded + sqlWithComment.contains("symbols_key='value%26with%3Dsymbols'") + sqlWithComment.startsWith("SELECT * FROM foo /*") + sqlWithComment.endsWith("*/") + } + + def "test SQL comment injection with null and empty custom field values"() { + setup: + injectSysConfig("dd.service", "SqlCommenter") + injectSysConfig("dd.env", "Test") + injectSysConfig("dd.version", "TestVersion") + + when: + String sqlWithComment = "" + + // Set up custom fields with null and empty values + SQLCommenterContext.put("null_key", null) + SQLCommenterContext.put("empty_key", "") + SQLCommenterContext.put("valid_key", "valid_value") + + sqlWithComment = SQLCommenter.inject( + "SELECT * FROM foo", + "my-service", + "mysql", + "h", + "n", + null, + false, + true + ) + + SQLCommenterContext.clear() + + then: + // Should not contain null or empty fields, but should contain valid field + !sqlWithComment.contains("null_key") + !sqlWithComment.contains("empty_key") + sqlWithComment.contains("valid_key='valid_value'") + sqlWithComment.startsWith("SELECT * FROM foo /*") + sqlWithComment.endsWith("*/") + } } diff --git a/dd-java-agent/instrumentation/jdbc/src/test/java/datadog/trace/instrumentation/jdbc/SQLCommenterContextTest.java b/dd-java-agent/instrumentation/jdbc/src/test/java/datadog/trace/instrumentation/jdbc/SQLCommenterContextTest.java new file mode 100644 index 00000000000..d54565f0b8d --- /dev/null +++ b/dd-java-agent/instrumentation/jdbc/src/test/java/datadog/trace/instrumentation/jdbc/SQLCommenterContextTest.java @@ -0,0 +1,174 @@ +package datadog.trace.instrumentation.jdbc; + +import static org.junit.Assert.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class SQLCommenterContextTest { + + @Before + public void setUp() { + SQLCommenterContext.setContextMap(null); + } + + @After + public void tearDown() { + SQLCommenterContext.setContextMap(null); + } + + @Test + public void testGetCopyOfContextMapEmpty() { + assertNull(SQLCommenterContext.getCopyOfContextMap()); + } + + @Test + public void testSetAndGetContextMap() { + Map contextMap = new HashMap<>(); + contextMap.put("key1", "value1"); + contextMap.put("key2", "value2"); + + SQLCommenterContext.setContextMap(contextMap); + + Map retrievedMap = SQLCommenterContext.getCopyOfContextMap(); + assertNotNull(retrievedMap); + assertEquals(2, retrievedMap.size()); + assertEquals("value1", retrievedMap.get("key1")); + assertEquals("value2", retrievedMap.get("key2")); + + // Verify it's a copy (modifications don't affect original) + retrievedMap.put("key3", "value3"); + Map retrievedAgain = SQLCommenterContext.getCopyOfContextMap(); + assertEquals(2, retrievedAgain.size()); + assertNull(retrievedAgain.get("key3")); + } + + @Test + public void testSetContextMapMakesInternalCopy() { + Map contextMap = new HashMap<>(); + contextMap.put("key1", "value1"); + + SQLCommenterContext.setContextMap(contextMap); + + // Modify the original map + contextMap.put("key2", "value2"); + + // Internal context should not be affected + Map retrievedMap = SQLCommenterContext.getCopyOfContextMap(); + assertEquals(1, retrievedMap.size()); + assertEquals("value1", retrievedMap.get("key1")); + assertNull(retrievedMap.get("key2")); + } + + @Test + public void testSetContextMapNull() { + Map contextMap = new HashMap<>(); + contextMap.put("key1", "value1"); + SQLCommenterContext.setContextMap(contextMap); + + // Verify context is set + assertNotNull(SQLCommenterContext.getCopyOfContextMap()); + + // Clear with null + SQLCommenterContext.setContextMap(null); + assertNull(SQLCommenterContext.getCopyOfContextMap()); + } + + @Test + public void testThreadIsolation() throws Exception { + Map mainContext = new HashMap<>(); + mainContext.put("main_key", "main_value"); + SQLCommenterContext.setContextMap(mainContext); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(2); + + // Start two threads with different contexts + Future future1 = + executor.submit( + () -> { + try { + Map thread1Context = new HashMap<>(); + thread1Context.put("thread1_key", "thread1_value"); + SQLCommenterContext.setContextMap(thread1Context); + + latch.countDown(); + latch.await(5, TimeUnit.SECONDS); + + // Each thread should only see its own context + Map context = SQLCommenterContext.getCopyOfContextMap(); + assertEquals(1, context.size()); + assertEquals("thread1_value", context.get("thread1_key")); + assertNull(context.get("thread2_key")); + assertNull(context.get("main_key")); // Thread local, so main context not visible + + return "thread1_done"; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + Future future2 = + executor.submit( + () -> { + try { + Map thread2Context = new HashMap<>(); + thread2Context.put("thread2_key", "thread2_value"); + SQLCommenterContext.setContextMap(thread2Context); + + latch.countDown(); + latch.await(5, TimeUnit.SECONDS); + + // Each thread should only see its own context + Map context = SQLCommenterContext.getCopyOfContextMap(); + assertEquals(1, context.size()); + assertEquals("thread2_value", context.get("thread2_key")); + assertNull(context.get("thread1_key")); + assertNull(context.get("main_key")); // Thread local, so main context not visible + + return "thread2_done"; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + assertEquals("thread1_done", future1.get(10, TimeUnit.SECONDS)); + assertEquals("thread2_done", future2.get(10, TimeUnit.SECONDS)); + + // Main thread should still have its context + Map mainContextAfter = SQLCommenterContext.getCopyOfContextMap(); + assertEquals(1, mainContextAfter.size()); + assertEquals("main_value", mainContextAfter.get("main_key")); + assertNull(mainContextAfter.get("thread1_key")); + assertNull(mainContextAfter.get("thread2_key")); + + executor.shutdown(); + } + + @Test + public void testContextPersistence() { + // Set initial context + Map context1 = new HashMap<>(); + context1.put("key1", "value1"); + SQLCommenterContext.setContextMap(context1); + + // Update context + Map context2 = new HashMap<>(); + context2.put("key2", "value2"); + SQLCommenterContext.setContextMap(context2); + + // Should only have the latest context + Map retrieved = SQLCommenterContext.getCopyOfContextMap(); + assertEquals(1, retrieved.size()); + assertEquals("value2", retrieved.get("key2")); + assertNull(retrieved.get("key1")); + } +} diff --git a/dd-trace-api/src/main/java/datadog/trace/api/SQLCommenter.java b/dd-trace-api/src/main/java/datadog/trace/api/SQLCommenter.java new file mode 100644 index 00000000000..d1ef62298c2 --- /dev/null +++ b/dd-trace-api/src/main/java/datadog/trace/api/SQLCommenter.java @@ -0,0 +1,105 @@ +package datadog.trace.api; + +import java.util.Map; + +/** + * Public API for accessing SQL comment context managed by the Datadog Java tracer. This API + * provides essential primitives for custom context management integration. + * + *

This API only works when the dd-trace-java agent is loaded. If the agent is not present, these + * methods will have no effect. Applications should handle the optional dependency by checking for + * class availability before calling these methods. + * + *

Usage example: + * + *

+ * // Get current context
+ * Map currentContext = SQLCommenter.getCopyOfContextMap();
+ *
+ * // Merge with custom fields and set new context
+ * Map newContext = new HashMap<>(currentContext != null ? currentContext : Map.of());
+ * newContext.put("tenant_id", "abc123");
+ * newContext.put("request_id", "xyz789");
+ * SQLCommenter.setContextMap(newContext);
+ *
+ * // Execute database operations...
+ *
+ * // Restore original context
+ * SQLCommenter.setContextMap(currentContext);
+ * 
+ */ +public class SQLCommenter { + + /** + * Gets a copy of the current thread's SQL comment context map. + * + * @return a copy of the current context map, or null if empty + */ + public static Map getCopyOfContextMap() { + return getContext().getCopyOfContextMap(); + } + + /** + * Sets the context map for the current thread. + * + * @param contextMap the new context map to set, or null to clear + */ + public static void setContextMap(Map contextMap) { + getContext().setContextMap(contextMap); + } + + // Direct access to the context + private static SQLCommenterContextAccess getContext() { + return SQLCommenterContextHolder.INSTANCE; + } + + private static class SQLCommenterContextHolder { + private static final SQLCommenterContextAccess INSTANCE = createInstance(); + + private static SQLCommenterContextAccess createInstance() { + try { + Class contextClass = + Class.forName("datadog.trace.instrumentation.jdbc.SQLCommenterContext"); + return new DirectSQLCommenterContextAccess(contextClass); + } catch (ClassNotFoundException | LinkageError e) { + // This should not happen if the dd-trace-java agent is properly loaded + throw new RuntimeException( + "SQLCommenterContext is not available. Ensure dd-trace-java agent is loaded.", e); + } + } + } + + // Direct access interface + private interface SQLCommenterContextAccess { + Map getCopyOfContextMap(); + + void setContextMap(Map contextMap); + } + + private static class DirectSQLCommenterContextAccess implements SQLCommenterContextAccess { + private final Class contextClass; + + public DirectSQLCommenterContextAccess(Class contextClass) { + this.contextClass = contextClass; + } + + @Override + @SuppressWarnings("unchecked") + public Map getCopyOfContextMap() { + try { + return (Map) contextClass.getMethod("getCopyOfContextMap").invoke(null); + } catch (Exception e) { + throw new RuntimeException("Failed to get copy of SQL comment context map", e); + } + } + + @Override + public void setContextMap(Map contextMap) { + try { + contextClass.getMethod("setContextMap", Map.class).invoke(null, contextMap); + } catch (Exception e) { + throw new RuntimeException("Failed to set SQL comment context map", e); + } + } + } +}