diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/DDLLMObsSpan.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/DDLLMObsSpan.java index b5283b73dad..04a7c4e958d 100644 --- a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/DDLLMObsSpan.java +++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/DDLLMObsSpan.java @@ -41,6 +41,8 @@ public class DDLLMObsSpan implements LLMObsSpan { private static final String SPAN_KIND = LLMOBS_TAG_PREFIX + Tags.SPAN_KIND; private static final String METADATA = LLMOBS_TAG_PREFIX + LLMObsTags.METADATA; private static final String TOOL_DEFINITIONS = LLMOBS_TAG_PREFIX + LLMObsTags.TOOL_DEFINITIONS; + private static final String AGENT_MANIFEST = LLMOBS_TAG_PREFIX + LLMObsTags.AGENT_MANIFEST; + private static final String MANUAL_FRAMEWORK = "AgentObs SDK"; private static final String PROMPT_TRACKING_INSTRUMENTATION_METHOD = LLMOBS_TAG_PREFIX + "prompt_tracking_instrumentation_method"; private static final String INSTRUMENTATION_METHOD_ANNOTATED = "annotated"; @@ -291,6 +293,64 @@ public void annotatePrompt(LLMObs.Prompt prompt) { span.setTag(PROMPT_TRACKING_INSTRUMENTATION_METHOD, INSTRUMENTATION_METHOD_ANNOTATED); } + @Override + public void annotateAgentManifest(LLMObs.AgentManifest manifest) { + if (finished || manifest == null) { + return; + } + if (!Tags.LLMOBS_AGENT_SPAN_KIND.equals(spanKind)) { + LOGGER.warn( + "dropping agent manifest on non-agent span kind; annotateAgentManifest is only supported for agent spans"); + return; + } + Map manifestMap = buildManifestMap(manifest); + if (!manifestMap.isEmpty()) { + manifestMap.put("framework", MANUAL_FRAMEWORK); + span.setTag(AGENT_MANIFEST, manifestMap); + } + } + + private Map buildManifestMap(LLMObs.AgentManifest manifest) { + Map map = new LinkedHashMap<>(); + CharSequence sn = span.getSpanName(); + String name = + manifest.getName() != null ? manifest.getName() : (sn != null ? sn.toString() : null); + if (name != null && !name.isEmpty()) { + map.put("name", name); + } + if (manifest.getInstructions() != null && !manifest.getInstructions().isEmpty()) { + map.put("instructions", manifest.getInstructions()); + } + if (manifest.getModel() != null && !manifest.getModel().isEmpty()) { + map.put("model", manifest.getModel()); + } + if (manifest.getModelSettings() != null && !manifest.getModelSettings().isEmpty()) { + map.put("model_settings", new LinkedHashMap<>(manifest.getModelSettings())); + } + if (manifest.getTools() != null && !manifest.getTools().isEmpty()) { + List> toolList = new ArrayList<>(); + for (LLMObs.AgentTool tool : manifest.getTools()) { + if (tool == null || tool.getName() == null || tool.getName().isEmpty()) { + LOGGER.warn("agent manifest tool missing required name; skipping"); + continue; + } + Map toolMap = new LinkedHashMap<>(); + toolMap.put("name", tool.getName()); + if (tool.getDescription() != null) { + toolMap.put("description", tool.getDescription()); + } + if (tool.getParameters() != null && !tool.getParameters().isEmpty()) { + toolMap.put("parameters", new LinkedHashMap<>(tool.getParameters())); + } + toolList.add(toolMap); + } + if (!toolList.isEmpty()) { + map.put("tools", toolList); + } + } + return map; + } + private static Map copyStringKeyedMap(Map source) { Map copy = new LinkedHashMap<>(); for (Map.Entry entry : source.entrySet()) { diff --git a/dd-java-agent/agent-llmobs/src/test/groovy/datadog/trace/llmobs/domain/DDLLMObsSpanTest.groovy b/dd-java-agent/agent-llmobs/src/test/groovy/datadog/trace/llmobs/domain/DDLLMObsSpanTest.groovy index bef904409a2..0c933fa7566 100644 --- a/dd-java-agent/agent-llmobs/src/test/groovy/datadog/trace/llmobs/domain/DDLLMObsSpanTest.groovy +++ b/dd-java-agent/agent-llmobs/src/test/groovy/datadog/trace/llmobs/domain/DDLLMObsSpanTest.groovy @@ -63,6 +63,7 @@ class DDLLMObsSpanTest extends DDSpecification{ private static final String OUTPUT = LLMOBS_TAG_PREFIX + "output" private static final String METADATA = LLMOBS_TAG_PREFIX + LLMObsTags.METADATA private static final String TOOL_DEFINITIONS = LLMOBS_TAG_PREFIX + LLMObsTags.TOOL_DEFINITIONS + private static final String AGENT_MANIFEST = LLMOBS_TAG_PREFIX + "agent_manifest" private static final String PROMPT_TRACKING_INSTRUMENTATION_METHOD = LLMOBS_TAG_PREFIX + "prompt_tracking_instrumentation_method" @@ -791,6 +792,159 @@ class DDLLMObsSpanTest extends DDSpecification{ innerSpan.getTag(LLMOBS_TAG_PREFIX + "owner") == "ml-platform" } + def "agent manifest full annotation sets correct tag"() { + setup: + def test = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "my-agent") + def settings = [temperature: 0.7, max_tokens: 1024] + def params = [city: [type: "string"]] + def tools = [LLMObs.AgentTool.from("get_weather", "Look up weather", params)] + def manifest = LLMObs.AgentManifest.builder() + .name("travel_desk") + .instructions("Book travel.") + .model("gpt-4o") + .modelSettings(settings) + .tools(tools) + .build() + + when: + test.annotateAgentManifest(manifest) + + then: + def innerSpan = (AgentSpan) test.span + def stored = (Map) innerSpan.getTag(AGENT_MANIFEST) + stored["name"] == "travel_desk" + stored["instructions"] == "Book travel." + stored["model"] == "gpt-4o" + stored["framework"] == "AgentObs SDK" + def ms = (Map) stored["model_settings"] + ms["temperature"] == 0.7 + ms["max_tokens"] == 1024 + def toolList = (List) stored["tools"] + toolList.size() == 1 + toolList[0]["name"] == "get_weather" + toolList[0]["description"] == "Look up weather" + toolList[0]["parameters"] == [city: [type: "string"]] + + cleanup: + test.finish() + } + + def "agent manifest name defaults to span name when not provided"() { + setup: + def test = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "my-agent") + def manifest = LLMObs.AgentManifest.builder().instructions("Do something.").build() + + when: + test.annotateAgentManifest(manifest) + + then: + def innerSpan = (AgentSpan) test.span + def stored = (Map) innerSpan.getTag(AGENT_MANIFEST) + stored["name"] == "my-agent" + stored["instructions"] == "Do something." + stored["framework"] == "AgentObs SDK" + + cleanup: + test.finish() + } + + def "agent manifest drops tool with null name"() { + setup: + def test = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "my-agent") + def validTool = LLMObs.AgentTool.from("valid-tool") + def badTool = LLMObs.AgentTool.from(null) + def manifest = LLMObs.AgentManifest.builder() + .tools([badTool, validTool]) + .build() + + when: + test.annotateAgentManifest(manifest) + + then: + def innerSpan = (AgentSpan) test.span + def stored = (Map) innerSpan.getTag(AGENT_MANIFEST) + def toolList = (List) stored["tools"] + toolList.size() == 1 + toolList[0]["name"] == "valid-tool" + + cleanup: + test.finish() + } + + def "agent manifest on non-agent span is silently dropped"() { + setup: + def test = llmObsSpan(Tags.LLMOBS_LLM_SPAN_KIND, "llm-span") + def manifest = LLMObs.AgentManifest.builder().name("agent").build() + + when: + test.annotateAgentManifest(manifest) + + then: + def innerSpan = (AgentSpan) test.span + innerSpan.getTag(AGENT_MANIFEST) == null + + cleanup: + test.finish() + } + + def "second annotateAgentManifest call overwrites the first"() { + setup: + def test = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "my-agent") + def first = LLMObs.AgentManifest.builder().name("first").instructions("v1").build() + def second = LLMObs.AgentManifest.builder().name("second").model("gpt-4o").build() + + when: + test.annotateAgentManifest(first) + test.annotateAgentManifest(second) + + then: + def innerSpan = (AgentSpan) test.span + def stored = (Map) innerSpan.getTag(AGENT_MANIFEST) + stored["name"] == "second" + stored["model"] == "gpt-4o" + !stored.containsKey("instructions") + + cleanup: + test.finish() + } + + def "agent manifest model_settings forwarded as-is"() { + setup: + def test = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "my-agent") + def manifest = LLMObs.AgentManifest.builder() + .name("agent") + .modelSettings([temperature: 0.5, custom_key: "custom_val"]) + .build() + + when: + test.annotateAgentManifest(manifest) + + then: + def innerSpan = (AgentSpan) test.span + def stored = (Map) innerSpan.getTag(AGENT_MANIFEST) + def ms = (Map) stored["model_settings"] + ms["temperature"] == 0.5 + ms["custom_key"] == "custom_val" + + cleanup: + test.finish() + } + + def "annotateAgentManifest null manifest is ignored"() { + setup: + def test = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "my-agent") + + when: + test.annotateAgentManifest(null) + + then: + def innerSpan = (AgentSpan) test.span + innerSpan.getTag(AGENT_MANIFEST) == null + + cleanup: + test.finish() + } + private LLMObsSpan llmObsSpan(String kind, name) { llmObsSpan(kind, name, null) } diff --git a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java index fcac3de284c..abac0b8d961 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java @@ -1109,4 +1109,139 @@ public Double getScore() { return score; } } + + /** A tool declared in an agent manifest. */ + public static final class AgentTool { + private final String name; + private final String description; + private final Map parameters; + + public static AgentTool from(String name) { + return new AgentTool(name, null, null); + } + + public static AgentTool from( + String name, @Nullable String description, @Nullable Map parameters) { + return new AgentTool(name, description, parameters); + } + + private AgentTool(String name, String description, Map parameters) { + this.name = name; + this.description = description; + this.parameters = + parameters == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(parameters)); + } + + public String getName() { + return name; + } + + @Nullable + public String getDescription() { + return description; + } + + @Nullable + public Map getParameters() { + return parameters; + } + } + + /** + * Declares the configuration of an agent span: what model it calls, what instructions it runs + * with, and which tools it has available. + * + *

Build via {@link AgentManifest#builder()} and pass to {@link + * LLMObsSpan#annotateAgentManifest(AgentManifest)}. Only applied on agent spans; ignored on other + * span kinds. A subsequent call on the same span overwrites the previous manifest. + */ + public static final class AgentManifest { + private final String name; + private final String instructions; + private final String model; + private final Map modelSettings; + private final List tools; + + public static Builder builder() { + return new Builder(); + } + + private AgentManifest(Builder builder) { + this.name = builder.name; + this.instructions = builder.instructions; + this.model = builder.model; + this.modelSettings = + builder.modelSettings == null + ? null + : Collections.unmodifiableMap(new LinkedHashMap<>(builder.modelSettings)); + this.tools = + builder.tools == null + ? null + : Collections.unmodifiableList(new ArrayList<>(builder.tools)); + } + + @Nullable + public String getName() { + return name; + } + + @Nullable + public String getInstructions() { + return instructions; + } + + @Nullable + public String getModel() { + return model; + } + + @Nullable + public Map getModelSettings() { + return modelSettings; + } + + @Nullable + public List getTools() { + return tools; + } + + public static final class Builder { + private String name; + private String instructions; + private String model; + private Map modelSettings; + private List tools; + + private Builder() {} + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder modelSettings(Map modelSettings) { + this.modelSettings = modelSettings; + return this; + } + + public Builder tools(List tools) { + this.tools = tools; + return this; + } + + public AgentManifest build() { + return new AgentManifest(this); + } + } + } } diff --git a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsSpan.java b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsSpan.java index efaf13123b7..8145037637f 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsSpan.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsSpan.java @@ -59,6 +59,15 @@ default void annotatePrompt(LLMObs.Prompt prompt) {} */ default void setToolDefinitions(List toolDefinitions) {} + /** + * Annotate an agent span with its manifest configuration. + * + *

This annotation is ignored for non-agent spans. + * + * @param agentManifest The agent manifest configuration + */ + default void annotateAgentManifest(LLMObs.AgentManifest agentManifest) {} + /** * Annotate the span with metadata * diff --git a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsTags.java b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsTags.java index 130cf610dc0..6be02e5af08 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsTags.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsTags.java @@ -13,4 +13,5 @@ public class LLMObsTags { public static final String MODEL_VERSION = "model_version"; public static final String MODEL_PROVIDER = "model_provider"; public static final String TOOL_DEFINITIONS = "tool_definitions"; + public static final String AGENT_MANIFEST = "agent_manifest"; } diff --git a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/noop/NoOpLLMObsSpan.java b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/noop/NoOpLLMObsSpan.java index bcde38ccfae..ec393ab6844 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/noop/NoOpLLMObsSpan.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/noop/NoOpLLMObsSpan.java @@ -18,6 +18,9 @@ public void annotateIO(String inputData, String outputData) {} @Override public void annotatePrompt(LLMObs.Prompt prompt) {} + @Override + public void annotateAgentManifest(LLMObs.AgentManifest manifest) {} + @Override public void setToolDefinitions(List toolDefinitions) {} diff --git a/dd-trace-api/src/test/java/datadog/trace/api/llmobs/LLMObsTest.java b/dd-trace-api/src/test/java/datadog/trace/api/llmobs/LLMObsTest.java index 584895070a5..286a5e5c071 100644 --- a/dd-trace-api/src/test/java/datadog/trace/api/llmobs/LLMObsTest.java +++ b/dd-trace-api/src/test/java/datadog/trace/api/llmobs/LLMObsTest.java @@ -208,6 +208,73 @@ void testPromptBuilderWithChatTemplate() { assertNotSame(chatTemplate, prompt.getChatTemplate()); } + @Test + void testAnnotateAgentManifestIsCompatibilityPreservingDefaultMethod() throws Exception { + assertTrue( + LLMObsSpan.class + .getMethod("annotateAgentManifest", LLMObs.AgentManifest.class) + .isDefault()); + } + + @Test + void testAgentManifestBuilderWithAllFields() { + Map modelSettings = new HashMap<>(); + modelSettings.put("temperature", 0.7); + modelSettings.put("max_tokens", 1024); + + List tools = + Arrays.asList( + LLMObs.AgentTool.from( + "get_weather", + "Look up the weather", + Collections.singletonMap("city", Collections.singletonMap("type", "string")))); + + LLMObs.AgentManifest manifest = + LLMObs.AgentManifest.builder() + .name("travel_desk") + .instructions("Book travel for the user.") + .model("gpt-4o") + .modelSettings(modelSettings) + .tools(tools) + .build(); + + assertEquals("travel_desk", manifest.getName()); + assertEquals("Book travel for the user.", manifest.getInstructions()); + assertEquals("gpt-4o", manifest.getModel()); + assertEquals(modelSettings, manifest.getModelSettings()); + assertNotSame(modelSettings, manifest.getModelSettings()); + assertEquals(1, manifest.getTools().size()); + assertNotSame(tools, manifest.getTools()); + assertEquals("get_weather", manifest.getTools().get(0).getName()); + assertEquals("Look up the weather", manifest.getTools().get(0).getDescription()); + } + + @Test + void testAgentManifestBuilderMinimal() { + LLMObs.AgentManifest manifest = LLMObs.AgentManifest.builder().build(); + assertNull(manifest.getName()); + assertNull(manifest.getInstructions()); + assertNull(manifest.getModel()); + assertNull(manifest.getModelSettings()); + assertNull(manifest.getTools()); + } + + @Test + void testAgentToolCreation() { + LLMObs.AgentTool tool = LLMObs.AgentTool.from("search"); + assertEquals("search", tool.getName()); + assertNull(tool.getDescription()); + assertNull(tool.getParameters()); + + Map params = new HashMap<>(); + params.put("query", Collections.singletonMap("type", "string")); + LLMObs.AgentTool fullTool = LLMObs.AgentTool.from("search", "Web search", params); + assertEquals("search", fullTool.getName()); + assertEquals("Web search", fullTool.getDescription()); + assertEquals(params, fullTool.getParameters()); + assertNotSame(params, fullTool.getParameters()); + } + @Test void testLLMMessageCreationWithToolCalls() { Map args = new HashMap<>(); diff --git a/dd-trace-core/src/main/java/datadog/trace/llmobs/writer/ddintake/LLMObsSpanMapper.java b/dd-trace-core/src/main/java/datadog/trace/llmobs/writer/ddintake/LLMObsSpanMapper.java index e5484d79432..42b7222e7d7 100644 --- a/dd-trace-core/src/main/java/datadog/trace/llmobs/writer/ddintake/LLMObsSpanMapper.java +++ b/dd-trace-core/src/main/java/datadog/trace/llmobs/writer/ddintake/LLMObsSpanMapper.java @@ -80,6 +80,8 @@ public class LLMObsSpanMapper implements RemoteMapper { private static final byte[] META = "meta".getBytes(StandardCharsets.UTF_8); private static final byte[] METADATA = "metadata".getBytes(StandardCharsets.UTF_8); + private static final byte[] AGENT_MANIFEST_KEY = + "agent_manifest".getBytes(StandardCharsets.UTF_8); private static final byte[] PROMPT = "prompt".getBytes(StandardCharsets.UTF_8); private static final byte[] SPAN_KIND = "span.kind".getBytes(StandardCharsets.UTF_8); private static final byte[] SPANS = "spans".getBytes(StandardCharsets.UTF_8); @@ -345,7 +347,8 @@ private static final class MetaWriter implements MetadataConsumer { LLMOBS_TAG_PREFIX + LLMObsTags.MODEL_PROVIDER, LLMOBS_TAG_PREFIX + LLMObsTags.MODEL_VERSION, LLMOBS_TAG_PREFIX + LLMObsTags.TOOL_DEFINITIONS, - LLMOBS_TAG_PREFIX + LLMObsTags.METADATA))); + LLMOBS_TAG_PREFIX + LLMObsTags.METADATA, + LLMOBS_TAG_PREFIX + LLMObsTags.AGENT_MANIFEST))); MetaWriter withWritable(Writable writable, Map errorInfo) { this.writable = writable; @@ -527,6 +530,14 @@ public void accept(Metadata metadata) { writable.writeString(entry.getKey(), null); writable.writeObject(entry.getValue(), null); } + } else if (key.equals(LLMObsTags.AGENT_MANIFEST) && val instanceof Map) { + Map manifestMap = (Map) val; + writable.writeUTF8(AGENT_MANIFEST_KEY); + writable.startMap(manifestMap.size()); + for (Map.Entry entry : manifestMap.entrySet()) { + writable.writeString(String.valueOf(entry.getKey()), null); + writable.writeObject(entry.getValue(), null); + } } else { writable.writeString(key, null); writable.writeObject(val, null); diff --git a/dd-trace-core/src/test/java/datadog/trace/llmobs/writer/ddintake/LLMObsSpanMapperTest.java b/dd-trace-core/src/test/java/datadog/trace/llmobs/writer/ddintake/LLMObsSpanMapperTest.java index 4ec1172525c..3924c31aaf4 100644 --- a/dd-trace-core/src/test/java/datadog/trace/llmobs/writer/ddintake/LLMObsSpanMapperTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/llmobs/writer/ddintake/LLMObsSpanMapperTest.java @@ -816,6 +816,77 @@ void testLLMObsSpanProcessorInputAndOutputRejectNull() { tracer.close(); } + @Test + void testLLMObsSpanMapperSerializesAgentManifest() throws Exception { + LLMObsSpanMapper mapper = new LLMObsSpanMapper(); + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + + Map manifest = new LinkedHashMap<>(); + manifest.put("name", "travel_desk"); + manifest.put("instructions", "Book travel."); + manifest.put("model", "gpt-4o"); + manifest.put("framework", "AgentObs SDK"); + + Map modelSettings = new LinkedHashMap<>(); + modelSettings.put("temperature", 0.7); + manifest.put("model_settings", modelSettings); + + List> tools = new ArrayList<>(); + Map tool = new LinkedHashMap<>(); + tool.put("name", "get_weather"); + tool.put("description", "Look up the weather."); + tools.add(tool); + manifest.put("tools", tools); + + AgentSpan agentSpan = + tracer + .buildSpan("datadog", "my-agent") + .withTag("_ml_obs_tag.span.kind", "agent") + .withTag("_ml_obs_tag.agent_manifest", manifest) + .start(); + agentSpan.setSpanType(InternalSpanTypes.LLMOBS); + agentSpan.finish(); + + Map spanData = serializeSingleSpan(mapper, agentSpan); + Map meta = (Map) spanData.get("meta"); + + assertTrue(meta.containsKey("agent_manifest")); + Map gotManifest = (Map) meta.get("agent_manifest"); + assertEquals("travel_desk", gotManifest.get("name")); + assertEquals("Book travel.", gotManifest.get("instructions")); + assertEquals("gpt-4o", gotManifest.get("model")); + assertEquals("AgentObs SDK", gotManifest.get("framework")); + assertEquals(modelSettings, gotManifest.get("model_settings")); + assertEquals(tools, gotManifest.get("tools")); + + tracer.close(); + } + + @Test + void testAgentManifestDoesNotAppearInTags() throws Exception { + LLMObsSpanMapper mapper = new LLMObsSpanMapper(); + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + + Map manifest = new LinkedHashMap<>(); + manifest.put("name", "my-agent"); + manifest.put("framework", "AgentObs SDK"); + + AgentSpan agentSpan = + tracer + .buildSpan("datadog", "my-agent") + .withTag("_ml_obs_tag.span.kind", "agent") + .withTag("_ml_obs_tag.agent_manifest", manifest) + .start(); + agentSpan.setSpanType(InternalSpanTypes.LLMOBS); + agentSpan.finish(); + + Map spanData = serializeSingleSpan(mapper, agentSpan); + List tags = (List) spanData.get("tags"); + assertFalse(tags.stream().anyMatch(t -> t.startsWith("agent_manifest:"))); + + tracer.close(); + } + private static AgentSpan newLlmObsSpan(CoreTracer tracer, String name, boolean drop) { AgentSpan span = tracer