getHeaders() {
- return headers;
- }
-
- /**
- * Returns the service name for OpenTelemetry traces.
- *
- * @return the service name, or null if not set
- */
- public String getServiceName() {
- return serviceName;
- }
-
- /**
- * Creates a new builder for constructing an OtelConfig.
- *
- * @return a new builder
- */
- public static Builder builder() {
- return new Builder();
- }
-
- /**
- * Creates a configuration from environment variables or system properties.
- *
- * System properties take precedence over environment variables.
- *
- * @return a configuration loaded from environment/system properties
- */
- public static OtelConfig fromEnv() {
- String endpoint = System.getProperty("langchain.otel.endpoint") != null
- ? System.getProperty("langchain.otel.endpoint")
- : System.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != null
- ? System.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
- : "http://localhost:4318/v1/traces";
-
- String enabledStr = System.getProperty("langchain.otel.enabled") != null
- ? System.getProperty("langchain.otel.enabled")
- : System.getenv("OTEL_EXPORTER_OTLP_ENABLED") != null
- ? System.getenv("OTEL_EXPORTER_OTLP_ENABLED")
- : "false";
- boolean enabled = "true".equalsIgnoreCase(enabledStr);
-
- String timeoutStr = System.getProperty("langchain.otel.timeout") != null
- ? System.getProperty("langchain.otel.timeout")
- : System.getenv("OTEL_EXPORTER_OTLP_TIMEOUT") != null
- ? System.getenv("OTEL_EXPORTER_OTLP_TIMEOUT")
- : "10";
- long timeoutSeconds = 10L;
- try {
- timeoutSeconds = Long.parseLong(timeoutStr);
- } catch (NumberFormatException e) {
- // Use default
- }
- Duration timeout = Duration.ofSeconds(timeoutSeconds);
-
- String serviceName = System.getProperty("langchain.otel.service.name") != null
- ? System.getProperty("langchain.otel.service.name")
- : System.getenv("OTEL_SERVICE_NAME") != null ? System.getenv("OTEL_SERVICE_NAME") : null;
-
- Builder configBuilder = builder().endpoint(endpoint).enabled(enabled).timeout(timeout);
- if (serviceName != null) {
- configBuilder.serviceName(serviceName);
- }
- return configBuilder.build();
- }
-
- /** Builder for OtelConfig. */
- public static final class Builder {
- private String endpoint = "http://localhost:4318/v1/traces";
- private boolean enabled = false;
- private Duration timeout = Duration.ofSeconds(10);
- private Map headers = new HashMap<>();
- private String serviceName = null;
-
- private Builder() {}
-
- /**
- * Sets the OpenTelemetry endpoint URL.
- *
- * @param endpoint the endpoint URL
- * @return this builder
- */
- public Builder endpoint(String endpoint) {
- this.endpoint = endpoint;
- return this;
- }
-
- /**
- * Sets whether OpenTelemetry export is enabled.
- *
- * @param enabled true to enable export, false to disable
- * @return this builder
- */
- public Builder enabled(boolean enabled) {
- this.enabled = enabled;
- return this;
- }
-
- /**
- * Sets the timeout for export requests.
- *
- * @param timeout the timeout duration
- * @return this builder
- */
- public Builder timeout(Duration timeout) {
- this.timeout = timeout;
- return this;
- }
-
- /**
- * Sets additional headers to include in export requests.
- *
- * @param headers a map of header names to values
- * @return this builder
- */
- public Builder headers(Map headers) {
- this.headers.clear();
- this.headers.putAll(headers);
- return this;
- }
-
- /**
- * Adds a header to include in export requests.
- *
- * @param name the header name
- * @param value the header value
- * @return this builder
- */
- public Builder putHeader(String name, String value) {
- this.headers.put(name, value);
- return this;
- }
-
- /**
- * Sets the service name for OpenTelemetry traces.
- *
- * @param serviceName the service name (e.g., "langsmith-java", "my-application")
- * @return this builder
- */
- public Builder serviceName(String serviceName) {
- this.serviceName = serviceName;
- return this;
- }
-
- /**
- * Builds the OtelConfig.
- *
- * @return a new OtelConfig instance
- */
- public OtelConfig build() {
- return new OtelConfig(this);
- }
- }
-}
diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelSpanCreator.java b/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelSpanCreator.java
deleted file mode 100644
index 4d05cc87..00000000
--- a/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelSpanCreator.java
+++ /dev/null
@@ -1,196 +0,0 @@
-package com.langchain.smith.otel;
-
-import io.opentelemetry.api.common.AttributeKey;
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.api.trace.Tracer;
-import io.opentelemetry.context.Context;
-
-/**
- * Utility class for creating OpenTelemetry spans with Gen AI semantic conventions.
- * Provides helper methods to create spans for LLM, tool, retrieval, and chain operations.
- */
-public final class OtelSpanCreator {
-
- private OtelSpanCreator() {}
-
- /**
- * Creates an LLM span with common gen_ai attributes pre-configured.
- *
- * @param tracer the OpenTelemetry tracer
- * @param name the span name
- * @param system the AI system (e.g., "openai", "anthropic")
- * @param model the model name (e.g., "gpt-4", "claude-3")
- * @param serviceName the service name
- * @param sessionId optional session ID
- * @return a started Span with gen_ai attributes set
- */
- public static Span createLlmSpan(
- Tracer tracer, String name, String system, String model, String serviceName, String sessionId) {
- Span span = tracer.spanBuilder(name).setParent(Context.current()).startSpan();
- span.setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "chat");
- span.setAttribute(AttributeKey.stringKey("gen_ai.system"), system);
- span.setAttribute(AttributeKey.stringKey("gen_ai.request.model"), model);
- if (serviceName != null) {
- span.setAttribute(AttributeKey.stringKey("service.name"), serviceName);
- }
- if (sessionId != null) {
- span.setAttribute(AttributeKey.stringKey("session.id"), sessionId);
- }
- return span;
- }
-
- /**
- * Sets the prompt/input on a span.
- *
- * @param span the span to set the input on
- * @param input the input/prompt text
- */
- public static void setInput(Span span, String input) {
- if (input != null) {
- span.setAttribute(AttributeKey.stringKey("gen_ai.prompt"), input);
- }
- }
-
- /**
- * Sets the completion/output on a span.
- *
- * @param span the span to set the output on
- * @param output the output/completion text
- */
- public static void setOutput(Span span, String output) {
- if (output != null) {
- span.setAttribute(AttributeKey.stringKey("gen_ai.completion"), output);
- }
- }
-
- /**
- * Sets the output messages in JSON format on a span.
- * This is used to represent the full message structure including tool calls.
- *
- * The messages JSON should follow OpenAI's message format:
- *
- * [
- * {
- * "role": "assistant",
- * "content": "Let me check the weather...",
- * "tool_calls": [
- * {
- * "id": "call_123",
- * "type": "function",
- * "function": {
- * "name": "get_weather",
- * "arguments": "{\"location\":\"San Francisco\"}"
- * }
- * }
- * ]
- * }
- * ]
- *
- *
- * @param span the span to set the output messages on
- * @param messagesJson JSON string containing the output messages array
- */
- public static void setOutputMessages(Span span, String messagesJson) {
- if (messagesJson != null) {
- span.setAttribute(AttributeKey.stringKey("gen_ai.output.messages"), messagesJson);
- }
- }
-
- /**
- * Sets the input messages in JSON format on a span.
- *
- * The messages JSON should follow OpenAI's message format:
- *
- * [
- * {"role": "user", "content": "What's the weather?"},
- * {"role": "system", "content": "You are a helpful assistant."}
- * ]
- *
- *
- * @param span the span to set the input messages on
- * @param messagesJson JSON string containing the input messages array
- */
- public static void setInputMessages(Span span, String messagesJson) {
- if (messagesJson != null) {
- span.setAttribute(AttributeKey.stringKey("gen_ai.input.messages"), messagesJson);
- }
- }
-
- /**
- * Sets token usage information on a span.
- *
- * @param span the span to set token usage on
- * @param inputTokens number of input tokens
- * @param outputTokens number of output tokens
- */
- public static void setTokenUsage(Span span, int inputTokens, int outputTokens) {
- span.setAttribute(AttributeKey.longKey("gen_ai.usage.input_tokens"), (long) inputTokens);
- span.setAttribute(AttributeKey.longKey("gen_ai.usage.output_tokens"), (long) outputTokens);
- }
-
- /**
- * Creates a tool span with common gen_ai attributes pre-configured.
- *
- * @param tracer the OpenTelemetry tracer
- * @param name the span name
- * @param toolName the tool name
- * @param serviceName the service name
- * @param sessionId optional session ID
- * @return a started Span with gen_ai attributes set
- */
- public static Span createToolSpan(
- Tracer tracer, String name, String toolName, String serviceName, String sessionId) {
- Span span = tracer.spanBuilder(name).setParent(Context.current()).startSpan();
- span.setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "tool");
- span.setAttribute(AttributeKey.stringKey("tool.name"), toolName);
- if (serviceName != null) {
- span.setAttribute(AttributeKey.stringKey("service.name"), serviceName);
- }
- if (sessionId != null) {
- span.setAttribute(AttributeKey.stringKey("session.id"), sessionId);
- }
- return span;
- }
-
- /**
- * Creates a retrieval span with common gen_ai attributes pre-configured.
- *
- * @param tracer the OpenTelemetry tracer
- * @param name the span name
- * @param serviceName the service name
- * @param sessionId optional session ID
- * @return a started Span with gen_ai attributes set
- */
- public static Span createRetrievalSpan(Tracer tracer, String name, String serviceName, String sessionId) {
- Span span = tracer.spanBuilder(name).setParent(Context.current()).startSpan();
- span.setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "retrieval");
- if (serviceName != null) {
- span.setAttribute(AttributeKey.stringKey("service.name"), serviceName);
- }
- if (sessionId != null) {
- span.setAttribute(AttributeKey.stringKey("session.id"), sessionId);
- }
- return span;
- }
-
- /**
- * Creates a chain/workflow span with common gen_ai attributes pre-configured.
- *
- * @param tracer the OpenTelemetry tracer
- * @param name the span name
- * @param serviceName the service name
- * @param sessionId optional session ID
- * @return a started Span with gen_ai attributes set
- */
- public static Span createChainSpan(Tracer tracer, String name, String serviceName, String sessionId) {
- Span span = tracer.spanBuilder(name).setParent(Context.current()).startSpan();
- span.setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "chat");
- if (serviceName != null) {
- span.setAttribute(AttributeKey.stringKey("service.name"), serviceName);
- }
- if (sessionId != null) {
- span.setAttribute(AttributeKey.stringKey("session.id"), sessionId);
- }
- return span;
- }
-}
diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelTraceExporter.java b/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelTraceExporter.java
deleted file mode 100644
index e8194955..00000000
--- a/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelTraceExporter.java
+++ /dev/null
@@ -1,281 +0,0 @@
-package com.langchain.smith.otel;
-
-import io.opentelemetry.api.OpenTelemetry;
-import io.opentelemetry.api.trace.Tracer;
-import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
-import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder;
-import io.opentelemetry.sdk.OpenTelemetrySdk;
-import io.opentelemetry.sdk.common.CompletableResultCode;
-import io.opentelemetry.sdk.resources.Resource;
-import io.opentelemetry.sdk.trace.SdkTracerProvider;
-import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
-import io.opentelemetry.semconv.ResourceAttributes;
-import java.time.Duration;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Manages OpenTelemetry SDK for exporting traces to OTLP endpoints.
- *
- * This class initializes the OpenTelemetry SDK with OTLP HTTP export capabilities,
- * providing a Tracer for creating spans with Gen AI semantic conventions.
- */
-public final class OtelTraceExporter {
- private static final Logger logger = LoggerFactory.getLogger(OtelTraceExporter.class);
- private static final String INSTRUMENTATION_NAME = "langsmith-java";
- private static final String INSTRUMENTATION_VERSION = "0.1.0";
-
- private final OtelConfig config;
- private final OpenTelemetry openTelemetry;
- private final Tracer tracer;
- private final SdkTracerProvider tracerProvider;
- private final String projectName;
-
- private OtelTraceExporter(
- OtelConfig config,
- OpenTelemetry openTelemetry,
- Tracer tracer,
- SdkTracerProvider tracerProvider,
- String projectName) {
- this.config = config;
- this.openTelemetry = openTelemetry;
- this.tracer = tracer;
- this.tracerProvider = tracerProvider;
- this.projectName = projectName != null ? projectName : "default";
- }
-
- /**
- * Get the OpenTelemetry Tracer for creating spans directly.
- * @return The OpenTelemetry Tracer instance
- */
- public Tracer getTracer() {
- return tracer;
- }
-
- /**
- * Get the project name configured for this exporter.
- * @return The project name
- */
- public String getProjectName() {
- return projectName;
- }
-
- /**
- * Shuts down the exporter and flushes any pending exports.
- *
- * @return a CompletableResultCode indicating success or failure
- */
- public CompletableResultCode shutdown() {
- return tracerProvider.shutdown();
- }
-
- /**
- * Flushes any pending exports.
- *
- * @return a CompletableResultCode indicating success or failure
- */
- public CompletableResultCode flush() {
- return tracerProvider.forceFlush();
- }
-
- /**
- * Creates an OtelTraceExporter from environment variables or system properties.
- *
- * @return a new OtelTraceExporter instance
- * @see OtelConfig#fromEnv()
- */
- public static OtelTraceExporter fromEnv() {
- return fromConfig(OtelConfig.fromEnv());
- }
-
- /**
- * Creates an OtelTraceExporter from a configuration.
- * Sets up the complete OpenTelemetry SDK with TracerProvider and BatchSpanProcessor.
- *
- * @param config the configuration to use
- * @return a new OtelTraceExporter instance
- */
- public static OtelTraceExporter fromConfig(OtelConfig config) {
- String serviceName = config.getServiceName() != null ? config.getServiceName() : "langsmith-app";
-
- Resource resource = Resource.getDefault().toBuilder()
- .put(ResourceAttributes.SERVICE_NAME, serviceName)
- .put(ResourceAttributes.SERVICE_VERSION, INSTRUMENTATION_VERSION)
- .build();
-
- if (!config.isEnabled()) {
- SdkTracerProvider tracerProvider = SdkTracerProvider.builder().build();
- OpenTelemetry openTelemetry =
- OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build();
- Tracer tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
- return new OtelTraceExporter(config, openTelemetry, tracer, tracerProvider, null);
- }
-
- // Build the OTLP HTTP exporter
- OtlpHttpSpanExporterBuilder exporterBuilder =
- OtlpHttpSpanExporter.builder().setEndpoint(config.getEndpoint()).setTimeout(config.getTimeout());
-
- // Add custom headers
- for (Map.Entry header : config.getHeaders().entrySet()) {
- exporterBuilder.addHeader(header.getKey(), header.getValue());
- }
-
- OtlpHttpSpanExporter exporter = exporterBuilder.build();
-
- BatchSpanProcessor spanProcessor = BatchSpanProcessor.builder(exporter)
- .setScheduleDelay(5, TimeUnit.SECONDS) // Batch every 5 seconds
- .setMaxQueueSize(2048)
- .setMaxExportBatchSize(512)
- .build();
-
- // Create TracerProvider with the processor
- SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
- .addResource(resource)
- .addSpanProcessor(spanProcessor)
- .build();
-
- // Create OpenTelemetry SDK
- OpenTelemetry openTelemetry =
- OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build();
-
- Tracer tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
-
- String projectName = config.getHeaders().get("Langsmith-Project");
- if (projectName == null) {
- projectName = "default";
- }
-
- // Log configuration for debugging
- logger.debug(
- "Created OpenTelemetry SDK with endpoint: {}, timeout: {}", config.getEndpoint(), config.getTimeout());
- logger.debug("Headers: {}", config.getHeaders());
- logger.debug("Service name: {}, Project name: {}", serviceName, projectName);
-
- return new OtelTraceExporter(config, openTelemetry, tracer, tracerProvider, projectName);
- }
-
- /**
- * Creates a builder for OtelTraceExporter.
- *
- * @return a new builder
- */
- public static Builder builder() {
- return new Builder();
- }
-
- /** Builder for OtelTraceExporter. */
- public static final class Builder {
- private OtelConfig config;
- private String endpoint;
- private Boolean enabled;
- private Duration timeout;
- private Map headers = new java.util.HashMap<>();
- private String serviceName;
-
- private Builder() {}
-
- /**
- * Sets the configuration to use.
- *
- * @param config the configuration
- * @return this builder
- */
- public Builder config(OtelConfig config) {
- this.config = config;
- return this;
- }
-
- /**
- * Sets the OpenTelemetry endpoint URL.
- *
- * @param endpoint the endpoint URL
- * @return this builder
- */
- public Builder endpoint(String endpoint) {
- this.endpoint = endpoint;
- return this;
- }
-
- /**
- * Sets whether OpenTelemetry export is enabled.
- *
- * @param enabled true to enable export, false to disable
- * @return this builder
- */
- public Builder enabled(boolean enabled) {
- this.enabled = enabled;
- return this;
- }
-
- /**
- * Sets the timeout for export requests.
- *
- * @param timeout the timeout duration
- * @return this builder
- */
- public Builder timeout(Duration timeout) {
- this.timeout = timeout;
- return this;
- }
-
- /**
- * Sets additional headers to include in export requests.
- *
- * @param headers a map of header names to values
- * @return this builder
- */
- public Builder headers(Map headers) {
- this.headers.clear();
- this.headers.putAll(headers);
- return this;
- }
-
- /**
- * Adds a header to include in export requests.
- *
- * @param name the header name
- * @param value the header value
- * @return this builder
- */
- public Builder putHeader(String name, String value) {
- this.headers.put(name, value);
- return this;
- }
-
- /**
- * Sets the service name for OpenTelemetry traces.
- *
- * @param serviceName the service name (e.g., "langsmith-java", "my-application")
- * @return this builder
- */
- public Builder serviceName(String serviceName) {
- this.serviceName = serviceName;
- return this;
- }
-
- /**
- * Builds the OtelTraceExporter.
- *
- * @return a new OtelTraceExporter instance
- */
- public OtelTraceExporter build() {
- OtelConfig.Builder configBuilder = config != null
- ? null
- : OtelConfig.builder()
- .endpoint(endpoint != null ? endpoint : "http://localhost:4318/v1/traces")
- .enabled(enabled != null ? enabled : false)
- .timeout(timeout != null ? timeout : Duration.ofSeconds(10))
- .headers(headers);
-
- if (configBuilder != null && serviceName != null) {
- configBuilder.serviceName(serviceName);
- }
-
- OtelConfig finalConfig = config != null ? config : configBuilder.build();
-
- return fromConfig(finalConfig);
- }
- }
-}
diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/ExperimentContext.java b/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/ExperimentContext.java
deleted file mode 100644
index c03d77e3..00000000
--- a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/ExperimentContext.java
+++ /dev/null
@@ -1,210 +0,0 @@
-package com.langchain.smith.wrappers.openai;
-
-import io.opentelemetry.context.Context;
-import io.opentelemetry.context.ContextKey;
-import io.opentelemetry.context.Scope;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * Immutable context for experiment metadata that will be automatically
- * attached to OpenTelemetry spans.
- *
- *
- * This class provides a convenient way to associate experiment metadata with
- * LLM calls without requiring manual span creation. When you set experiment
- * context using this class, the wrapped OpenAI client will automatically
- * attach it to the spans it creates.
- *
- *
- * This class follows the OpenTelemetry Context pattern with immutable
- * context objects and explicit scope management.
- *
- *
- * Example usage (recommended - try-with-resources):
- *
- *
{@code
- * // Convenience method (recommended)
- * try (Scope scope = ExperimentContext.withExperiment("example-123", "session-789")) {
- * ChatCompletion completion = client.chat().completions().create(params);
- * }
- * }
- *
- *
- * This is particularly useful for running experiments where you want to link
- * LLM traces to specific dataset examples in LangSmith.
- *
- *
- * Thread Safety: This class is thread-safe. Each thread maintains its own
- * context via OpenTelemetry's Context mechanism. Context instances are
- * immutable and safe to share across threads.
- */
-public final class ExperimentContext {
-
- private static final ContextKey CONTEXT_KEY = ContextKey.named("langsmith-experiment-context");
-
- private final ExperimentData data;
-
- private ExperimentContext(ExperimentData data) {
- this.data = data;
- }
-
- /**
- * Gets the current ExperimentContext from the OpenTelemetry Context.
- * Returns an empty context if no context has been set.
- *
- * @return the current ExperimentContext
- */
- public static ExperimentContext current() {
- Context otelContext = Context.current();
- ExperimentData data = otelContext.get(CONTEXT_KEY);
- return new ExperimentContext(data != null ? data : ExperimentData.empty());
- }
-
- /**
- * Convenience method to set all experiment context values and return a Scope.
- * This is the recommended method for running experiments as it properly links runs to
- * the experiment session.
- *
- * {@code
- * try (Scope scope = ExperimentContext.withExperiment(example.id(), session.id())) {
- * ChatCompletion completion = client.chat().completions().create(params);
- * }
- * }
- *
- * @param exampleId the reference example ID from your LangSmith dataset
- * @param sessionId the session/experiment UUID
- * @return a Scope that will restore the previous context when closed
- * @throws IllegalArgumentException if exampleId or sessionId is null or empty
- */
- public static Scope withExperiment(String exampleId, String sessionId) {
- if (exampleId == null || exampleId.trim().isEmpty()) {
- throw new IllegalArgumentException("exampleId cannot be null or empty");
- }
- if (sessionId == null || sessionId.trim().isEmpty()) {
- throw new IllegalArgumentException("sessionId cannot be null or empty");
- }
- return current()
- .withReferenceExampleId(exampleId)
- .withSessionId(sessionId)
- .makeCurrent();
- }
-
- /**
- * Returns a new ExperimentContext with the reference example ID set.
- *
- * @param exampleId the reference example ID from your LangSmith dataset
- * @return a new ExperimentContext with the updated value
- * @throws IllegalArgumentException if exampleId is null or empty
- */
- public ExperimentContext withReferenceExampleId(String exampleId) {
- if (exampleId == null || exampleId.trim().isEmpty()) {
- throw new IllegalArgumentException("exampleId cannot be null or empty");
- }
- return new ExperimentContext(data.withReferenceExampleId(exampleId));
- }
-
- /**
- * Returns a new ExperimentContext with the session ID set.
- *
- * @param sessionId the session/experiment UUID from LangSmith
- * @return a new ExperimentContext with the updated value
- * @throws IllegalArgumentException if sessionId is null or empty
- */
- public ExperimentContext withSessionId(String sessionId) {
- if (sessionId == null || sessionId.trim().isEmpty()) {
- throw new IllegalArgumentException("sessionId cannot be null or empty");
- }
- return new ExperimentContext(data.withSessionId(sessionId));
- }
-
- /**
- * Returns a new ExperimentContext with custom metadata added.
- * Metadata will be attached to spans as attributes prefixed with "langsmith.metadata.".
- *
- * @param key the metadata key
- * @param value the metadata value
- * @return a new ExperimentContext with the updated metadata
- * @throws IllegalArgumentException if key or value is null or empty
- */
- public ExperimentContext withMetadata(String key, String value) {
- if (key == null || key.trim().isEmpty()) {
- throw new IllegalArgumentException("metadata key cannot be null or empty");
- }
- if (value == null || value.trim().isEmpty()) {
- throw new IllegalArgumentException("metadata value cannot be null or empty");
- }
- return new ExperimentContext(data.withMetadata(key, value));
- }
-
- /**
- * Makes this ExperimentContext the current context in the OpenTelemetry Context.
- * Returns a Scope that will restore the previous context when closed.
- */
- private Scope makeCurrent() {
- Context otelContext = Context.current().with(CONTEXT_KEY, this.data);
- return otelContext.makeCurrent();
- }
-
- /**
- * Gets the reference example ID.
- *
- * @return an Optional containing the reference example ID, or empty if not set
- */
- public Optional getReferenceExampleId() {
- return Optional.ofNullable(data.referenceExampleId);
- }
-
- /**
- * Gets the session ID.
- *
- * @return an Optional containing the session ID, or empty if not set
- */
- public Optional getSessionId() {
- return Optional.ofNullable(data.sessionId);
- }
-
- /**
- * Gets all custom metadata as an unmodifiable map.
- *
- * @return an unmodifiable map of metadata key-value pairs
- */
- public Map getMetadata() {
- return data.metadata;
- }
-
- /**
- * Immutable data holder for experiment context values.
- */
- private static class ExperimentData {
- final String referenceExampleId;
- final String sessionId;
- final Map metadata;
-
- private ExperimentData(String referenceExampleId, String sessionId, Map metadata) {
- this.referenceExampleId = referenceExampleId;
- this.sessionId = sessionId;
- this.metadata = Collections.unmodifiableMap(new HashMap<>(metadata));
- }
-
- static ExperimentData empty() {
- return new ExperimentData(null, null, new HashMap<>());
- }
-
- ExperimentData withReferenceExampleId(String exampleId) {
- return new ExperimentData(exampleId, sessionId, metadata);
- }
-
- ExperimentData withSessionId(String sessionId) {
- return new ExperimentData(referenceExampleId, sessionId, metadata);
- }
-
- ExperimentData withMetadata(String key, String value) {
- Map newMetadata = new HashMap<>(metadata);
- newMetadata.put(key, value);
- return new ExperimentData(referenceExampleId, sessionId, newMetadata);
- }
- }
-}
diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.java b/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.java
deleted file mode 100644
index 7e772094..00000000
--- a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.java
+++ /dev/null
@@ -1,592 +0,0 @@
-package com.langchain.smith.wrappers.openai;
-
-import io.opentelemetry.api.OpenTelemetry;
-import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
-import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder;
-import io.opentelemetry.sdk.OpenTelemetrySdk;
-import io.opentelemetry.sdk.resources.Resource;
-import io.opentelemetry.sdk.trace.SdkTracerProvider;
-import io.opentelemetry.sdk.trace.SpanProcessor;
-import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
-import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
-import io.opentelemetry.sdk.trace.export.SpanExporter;
-import java.util.concurrent.TimeUnit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Configuration utility for setting up OpenTelemetry to export traces to
- * LangSmith.
- *
- *
- * This class provides a convenient way to configure OpenTelemetry with
- * LangSmith's OTLP
- * endpoint using a Builder pattern. Configuration can be done programmatically
- * or via environment variables.
- *
- *
- * Example usage:
- *
- *
{@code
- * // Configure OpenTelemetry for LangSmith before using the wrapper
- * OpenTelemetryConfig.builder()
- * .apiKey("your-langsmith-api-key")
- * .projectName("your-project-name")
- * .build();
- *
- * // Or using only environment variables (LANGSMITH_API_KEY, LANGSMITH_PROJECT)
- * OpenTelemetryConfig.builder().build();
- *
- * // Now use the wrapped client - traces will be sent to LangSmith
- * WrappedOpenAIClient client = OpenAIWrappers.wrapFromEnv();
- * }
- */
-public final class OpenTelemetryConfig {
- private static final Logger logger = LoggerFactory.getLogger(OpenTelemetryConfig.class);
-
- private OpenTelemetryConfig() {
- // Utility class
- }
-
- /**
- * Default LangSmith base URL.
- * Can be overridden by setting LANGSMITH_ENDPOINT environment variable or
- * by passing a custom base URL to the configuration methods.
- */
- public static final String DEFAULT_BASE_URL = "https://api.smith.langchain.com";
-
- /**
- * OTLP traces endpoint path.
- * This path is appended to the base URL to construct the full OTLP endpoint.
- */
- public static final String OTLP_TRACES_PATH = "/otel/v1/traces";
-
- /**
- * Span processor type for configuring how spans are exported.
- */
- public enum SpanProcessorType {
- /**
- * BatchSpanProcessor - Queues spans and exports them in batches.
- * Best for production use - efficient and non-blocking.
- */
- BATCH,
-
- /**
- * SimpleSpanProcessor - Exports spans synchronously on span.end().
- * Best for testing - predictable but blocks application thread.
- * WARNING: Not recommended for production due to performance impact.
- */
- SIMPLE
- }
-
- /**
- * Builder for configuring OpenTelemetry for LangSmith.
- * Provides a fluent API for setting only the parameters you need.
- *
- *
- * Example usage:
- *
{@code
- * OpenTelemetry otel = OpenTelemetryConfig.builder()
- * .apiKey("your-api-key")
- * .projectName("MyProject")
- * .processorType(SpanProcessorType.SIMPLE)
- * .maxBatchSize(1)
- * .build();
- * }
- */
- public static class Builder {
- private String apiKey;
- private String projectName;
- private String serviceName;
- private String baseUrl;
- private SpanProcessorType processorType = SpanProcessorType.BATCH;
- private int maxBatchSize = 512;
-
- private Builder() {
- // Initialize from environment variables as defaults
- this.apiKey = System.getenv("LANGSMITH_API_KEY");
- this.projectName = System.getenv("LANGSMITH_PROJECT");
- this.serviceName = System.getenv("OTEL_SERVICE_NAME");
- this.baseUrl = System.getenv("LANGSMITH_ENDPOINT");
- }
-
- /**
- * Sets the LangSmith API key (optional, defaults to LANGSMITH_API_KEY env var).
- *
- *
- * This is your authentication key for LangSmith. You can find it in your
- * LangSmith account settings.
- *
- *
- * If not set explicitly, the builder will use the value from the
- * LANGSMITH_API_KEY environment variable. At least one of these must be provided.
- *
- * @param apiKey your LangSmith API key
- * @return this Builder for method chaining
- * @throws IllegalStateException if apiKey is null or empty when build() is called
- */
- public Builder apiKey(String apiKey) {
- this.apiKey = apiKey;
- return this;
- }
-
- /**
- * Sets the LangSmith project name (optional, defaults to LANGSMITH_PROJECT env var).
- *
- *
- * If set, all traces will be sent to this project in LangSmith. The project
- * will be created automatically if it doesn't exist.
- *
- *
- * If not set explicitly, the builder will use the value from the
- * LANGSMITH_PROJECT environment variable.
- *
- *
- * Note: For experiments, you may want to leave this unset and instead
- * use {@link ExperimentContext#withExperiment(String, String)} to set
- * the session ID via span attributes. This prevents the project name from
- * overwriting the session's reference_dataset_id.
- *
- * @param projectName your LangSmith project name
- * @return this Builder for method chaining
- */
- public Builder projectName(String projectName) {
- this.projectName = projectName;
- return this;
- }
-
- /**
- * Sets the service name for OpenTelemetry (optional, defaults to OTEL_SERVICE_NAME env var).
- *
- *
- * This identifies your application in the traces. If not set explicitly,
- * the builder will use the value from the OTEL_SERVICE_NAME environment variable,
- * or fall back to "langsmith-java-otel-wrappers" if not set.
- *
- *
- * The service name appears in the trace metadata and can be used to filter
- * traces from different services.
- *
- * @param serviceName the service name to identify your application
- * @return this Builder for method chaining
- */
- public Builder serviceName(String serviceName) {
- this.serviceName = serviceName;
- return this;
- }
-
- /**
- * Sets the LangSmith base URL (optional, defaults to LANGSMITH_ENDPOINT env var).
- *
- *
- * The base URL is used to construct the OTLP endpoint by appending "/otel/v1/traces".
- * If not set explicitly, the builder will use the value from the LANGSMITH_ENDPOINT
- * environment variable, or default to "https://api.smith.langchain.com".
- *
- *
- * This is useful for:
- *
- * - Self-hosted LangSmith instances
- * - Internal/development environments
- * - Testing against different LangSmith deployments
- *
- *
- * @param baseUrl the LangSmith base URL (e.g., "https://dev.api.smith.langchain.com")
- * @return this Builder for method chaining
- */
- public Builder baseUrl(String baseUrl) {
- this.baseUrl = baseUrl;
- return this;
- }
-
- /**
- * Sets the span processor type (optional).
- *
- *
- * Determines how spans are processed and exported:
- *
- *
- * - BATCH (default, recommended for production):
- *
- * - Queues spans and exports them in batches
- * - Non-blocking - doesn't slow down your application
- * - Efficient - reduces network overhead
- * - May have a slight delay before spans appear in LangSmith
- *
- *
- * - SIMPLE (recommended for testing/examples):
- *
- * - Exports spans immediately when span.end() is called
- * - Blocking - waits for export to complete
- * - Predictable - spans appear in LangSmith right away
- * - Warning: Can impact performance, not recommended for production
- *
- *
- *
- *
- *
- * When to use SIMPLE:
- *
- * - Short-lived applications or scripts
- * - Testing and debugging
- * - When you need immediate visibility of traces
- *
- *
- *
- * When to use BATCH:
- *
- * - Production applications
- * - High-throughput services
- * - Long-running applications
- * - When performance is critical
- *
- *
- * @param processorType the span processor type (BATCH or SIMPLE)
- * @return this Builder for method chaining
- * @see SpanProcessorType
- */
- public Builder processorType(SpanProcessorType processorType) {
- this.processorType = processorType;
- return this;
- }
-
- /**
- * Sets the maximum batch size for span export (optional).
- *
- *
- * Only applies to BATCH processor type. Ignored when using SIMPLE processor.
- *
- *
- * Controls how many spans are batched together before triggering an export:
- *
- * - Default: 512 - Good balance between efficiency and latency
- * - 1 - Exports immediately (still non-blocking unlike SIMPLE)
- * - Higher values - More efficient but increased latency
- *
- *
- *
- * Setting to 1: If you want immediate export but don't want to block
- * your application thread, use BATCH processor with maxBatchSize=1 instead of
- * SIMPLE processor. This gives you non-blocking immediate export.
- *
- *
- * Example for immediate non-blocking export:
- *
{@code
- * OpenTelemetryConfig.builder()
- * .apiKey(apiKey)
- * .processorType(SpanProcessorType.BATCH)
- * .maxBatchSize(1) // Export immediately but don't block
- * .build();
- * }
- *
- * @param maxBatchSize the maximum batch size (1-512, default 512)
- * @return this Builder for method chaining
- */
- public Builder maxBatchSize(int maxBatchSize) {
- this.maxBatchSize = maxBatchSize;
- return this;
- }
-
- /**
- * Builds and configures the OpenTelemetry instance.
- *
- * @return the configured OpenTelemetry instance
- * @throws IllegalStateException if apiKey is not set
- */
- public OpenTelemetry build() {
- // Validate required fields
- if (apiKey == null || apiKey.isEmpty()) {
- throw new IllegalStateException(
- "LangSmith API key is required. Set it using apiKey() or LANGSMITH_API_KEY environment"
- + " variable.");
- }
-
- // Build OTLP endpoint from base URL
- String endpointUrl = buildOtlpEndpoint(baseUrl);
-
- // Create OTLP HTTP exporter configured for LangSmith
- OtlpHttpSpanExporterBuilder exporterBuilder =
- OtlpHttpSpanExporter.builder().setEndpoint(endpointUrl).addHeader("x-api-key", apiKey);
-
- // Only add project header if projectName is not null and not empty
- if (projectName != null && !projectName.isEmpty()) {
- exporterBuilder.addHeader("Langsmith-Project", projectName);
- }
-
- OtlpHttpSpanExporter spanExporter = exporterBuilder.build();
-
- // Wrap exporter to log export errors
- SpanExporter loggingExporter = new LoggingSpanExporter(spanExporter);
-
- // Create resource with service name
- Resource resource = Resource.getDefault()
- .merge(Resource.builder()
- .put("service.name", serviceName != null ? serviceName : "langsmith-java-otel-wrappers")
- .build());
-
- // Build and configure span processor based on type
- SpanProcessor spanProcessor;
- if (processorType == SpanProcessorType.SIMPLE) {
- // SimpleSpanProcessor - exports synchronously on span.end()
- // Good for testing, but blocks the application thread
- spanProcessor = SimpleSpanProcessor.create(loggingExporter);
- } else {
- // BatchSpanProcessor - queues and exports in batches
- // Good for production, non-blocking
- // If maxBatchSize is 1, spans are exported immediately as they complete
- spanProcessor = BatchSpanProcessor.builder(loggingExporter)
- .setScheduleDelay(100, TimeUnit.MILLISECONDS) // Export every 100ms
- .setMaxExportBatchSize(maxBatchSize) // Trigger export when batch reaches this size
- .setExporterTimeout(5, TimeUnit.SECONDS) // 5 second timeout
- .build();
- }
-
- SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
- .addSpanProcessor(spanProcessor)
- .setResource(resource)
- .build();
-
- return OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).buildAndRegisterGlobal();
- }
- }
-
- /**
- * Creates a new Builder for configuring OpenTelemetry.
- *
- * @return a new Builder instance
- */
- public static Builder builder() {
- return new Builder();
- }
-
- /**
- * Forces flushing of all pending spans to ensure they are exported.
- * This should be called before application shutdown to ensure all spans
- * are sent to LangSmith.
- *
- * @return true if flush completed successfully, false otherwise
- */
- public static boolean flush() {
- return flush(5, TimeUnit.SECONDS);
- }
-
- /**
- * Forces flushing of all pending spans to ensure they are exported.
- * This should be called before application shutdown to ensure all spans
- * are sent to LangSmith.
- *
- * @param timeout the maximum time to wait for flush to complete
- * @param unit the time unit of the timeout
- * @return true if flush completed successfully, false otherwise
- */
- public static boolean flush(long timeout, TimeUnit unit) {
- OpenTelemetry openTelemetry = io.opentelemetry.api.GlobalOpenTelemetry.get();
- if (openTelemetry instanceof OpenTelemetrySdk) {
- // Don't close the SDK instance - it's the global instance that should remain
- // alive
- OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetry;
- SdkTracerProvider tracerProvider = sdk.getSdkTracerProvider();
- if (tracerProvider != null) {
- try {
- io.opentelemetry.sdk.common.CompletableResultCode result = tracerProvider.forceFlush();
- result.join(timeout, unit);
- if (!result.isSuccess()) {
- logger.warn("Flush did not complete successfully");
- }
- return result.isSuccess();
- } catch (Exception e) {
- logger.warn("Failed to flush spans", e);
- return false;
- }
- }
- }
- return true;
- }
-
- /**
- * Builds the OTLP endpoint URL from a base URL.
- *
- *
- * The endpoint is constructed as: baseUrl + OTLP_TRACES_PATH
- *
- *
- * If baseUrl is null or empty, defaults to DEFAULT_BASE_URL.
- *
- * @param baseUrl the base URL (can be null)
- * @return the OTLP endpoint URL
- */
- private static String buildOtlpEndpoint(String baseUrl) {
- // Use provided base URL or default
- String effectiveBaseUrl = baseUrl;
- if (effectiveBaseUrl == null || effectiveBaseUrl.isEmpty()) {
- effectiveBaseUrl = System.getenv("LANGSMITH_ENDPOINT");
- }
- if (effectiveBaseUrl == null || effectiveBaseUrl.isEmpty()) {
- effectiveBaseUrl = DEFAULT_BASE_URL;
- }
-
- // Remove trailing slash if present
- if (effectiveBaseUrl.endsWith("/")) {
- effectiveBaseUrl = effectiveBaseUrl.substring(0, effectiveBaseUrl.length() - 1);
- }
-
- return effectiveBaseUrl + OTLP_TRACES_PATH;
- }
-
- /**
- * Shuts down the OpenTelemetry SDK and ensures all spans are exported.
- * This should be called before application shutdown.
- */
- public static void shutdown() {
- OpenTelemetry openTelemetry = io.opentelemetry.api.GlobalOpenTelemetry.get();
- if (openTelemetry instanceof OpenTelemetrySdk) {
- // Don't close the SDK instance - it's the global instance that should remain
- // alive
- @SuppressWarnings("resource")
- OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetry;
- SdkTracerProvider tracerProvider = sdk.getSdkTracerProvider();
- if (tracerProvider != null) {
- try {
- tracerProvider.shutdown().join(5, TimeUnit.SECONDS);
- } catch (Exception e) {
- logger.warn("Failed to shutdown OpenTelemetry", e);
- }
- }
- }
- }
-
- /**
- * Wrapper SpanExporter that logs export errors to help debug issues.
- */
- private static class LoggingSpanExporter implements SpanExporter {
- private final SpanExporter delegate;
- private static final boolean DEBUG =
- Boolean.getBoolean("langsmith.debug") || "true".equalsIgnoreCase(System.getenv("LANGSMITH_DEBUG"));
-
- LoggingSpanExporter(SpanExporter delegate) {
- this.delegate = delegate;
- }
-
- @Override
- public io.opentelemetry.sdk.common.CompletableResultCode export(
- java.util.Collection spans) {
- if (DEBUG) {
- logger.debug("[LangSmith] Exporting " + spans.size() + " span(s):");
- for (io.opentelemetry.sdk.trace.data.SpanData span : spans) {
- logger.debug(" - " + span.getName()
- + " (kind=" + span.getKind()
- + ", attributes=" + span.getAttributes().size() + ")");
- }
- }
-
- io.opentelemetry.sdk.common.CompletableResultCode result = delegate.export(spans);
-
- // For SimpleSpanProcessor, wait for the result synchronously to get immediate
- // feedback
- // For BatchSpanProcessor, this will return immediately but we can still check
- // status
- if (DEBUG) {
- // Wait up to 5 seconds for the result
- try {
- result.join(5, java.util.concurrent.TimeUnit.SECONDS);
- if (!result.isSuccess()) {
- logger.error("[LangSmith ERROR] Failed to export " + spans.size() + " span(s) to LangSmith");
-
- // Try to get more error details
- try {
- // Check if there's an exception
- java.lang.reflect.Method getExceptionMethod =
- result.getClass().getMethod("getException");
- Throwable exception = (Throwable) getExceptionMethod.invoke(result);
- if (exception != null) {
- logger.error(" Exception: "
- + exception.getClass().getName() + ": " + exception.getMessage());
- if (exception.getCause() != null) {
- logger.error(" Caused by: "
- + exception.getCause().getClass().getName() + ": "
- + exception.getCause().getMessage());
- }
- // Print full stack trace in debug mode
- java.io.StringWriter sw = new java.io.StringWriter();
- exception.printStackTrace(new java.io.PrintWriter(sw));
- logger.debug(" Stack trace:\n" + sw.toString());
- }
- } catch (Exception e) {
- // Reflection failed, try to get error message another way
- logger.debug(" Could not extract exception details: " + e.getMessage());
- }
-
- // Log span details for debugging
- logger.error(" Spans being exported:");
- for (io.opentelemetry.sdk.trace.data.SpanData span : spans) {
- logger.error(" - " + span.getName() + " (traceId=" + span.getTraceId() + ", spanId="
- + span.getSpanId() + ")");
- logger.debug(" Attributes: " + span.getAttributes());
- }
-
- logger.error(" This usually indicates a network error, authentication problem, or invalid span"
- + " data");
- logger.error(" Check your LANGSMITH_API_KEY and network connectivity");
- } else {
- logger.debug("[LangSmith] Successfully exported " + spans.size() + " span(s)");
- }
- } catch (Exception e) {
- logger.error("[LangSmith ERROR] Exception waiting for export result", e);
- }
- } else {
- // Without DEBUG, still log errors but don't block
- result.whenComplete(() -> {
- if (!result.isSuccess()) {
- logger.error("[LangSmith ERROR] Failed to export " + spans.size() + " span(s) to LangSmith");
-
- // Try to get exception details even without DEBUG
- try {
- java.lang.reflect.Method getExceptionMethod =
- result.getClass().getMethod("getException");
- Throwable exception = (Throwable) getExceptionMethod.invoke(result);
- if (exception != null) {
- logger.error(" Error: " + exception.getMessage());
- if (exception.getCause() != null) {
- logger.error(" Caused by: "
- + exception.getCause().getMessage());
- }
- }
- } catch (Exception e) {
- // Ignore reflection errors
- }
-
- logger.error(" This usually indicates a network error, authentication problem, or invalid span"
- + " data");
- logger.error(" Check your LANGSMITH_API_KEY and network connectivity");
- logger.error(" Set LANGSMITH_DEBUG=true for more details");
- }
- });
- }
- return result;
- }
-
- @Override
- public io.opentelemetry.sdk.common.CompletableResultCode flush() {
- if (DEBUG) {
- logger.debug("[LangSmith] Flushing spans...");
- }
- io.opentelemetry.sdk.common.CompletableResultCode result = delegate.flush();
- result.whenComplete(() -> {
- if (!result.isSuccess()) {
- logger.error("[LangSmith ERROR] Failed to flush spans");
- } else if (DEBUG) {
- logger.debug("[LangSmith] Flush completed successfully");
- }
- });
- return result;
- }
-
- @Override
- public io.opentelemetry.sdk.common.CompletableResultCode shutdown() {
- if (DEBUG) {
- logger.debug("[LangSmith] Shutting down span exporter...");
- }
- return delegate.shutdown();
- }
- }
-}
diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/TracingUtils.java b/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/TracingUtils.java
deleted file mode 100644
index 4660954a..00000000
--- a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/TracingUtils.java
+++ /dev/null
@@ -1,120 +0,0 @@
-package com.langchain.smith.wrappers.openai;
-
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.api.trace.SpanBuilder;
-import io.opentelemetry.api.trace.SpanKind;
-import io.opentelemetry.api.trace.Tracer;
-
-/** Internal utility for OpenTelemetry span creation and management. */
-final class TracingUtils {
-
- private static final String INSTRUMENTATION_NAME = "langsmith-java-otel-wrappers";
-
- private TracingUtils() {}
-
- static Tracer getTracer() {
- try {
- Tracer tracer = io.opentelemetry.api.GlobalOpenTelemetry.get().getTracer(INSTRUMENTATION_NAME);
-
- boolean debug =
- Boolean.getBoolean("langsmith.debug") || "true".equalsIgnoreCase(System.getenv("LANGSMITH_DEBUG"));
- if (debug) {
- io.opentelemetry.api.OpenTelemetry otel = io.opentelemetry.api.GlobalOpenTelemetry.get();
- boolean isNoop = otel.getClass().getName().contains("Noop");
- System.out.println("[TracingUtils] Tracer obtained: "
- + tracer.getClass().getName() + ", OpenTelemetry isNoop: " + isNoop);
- }
-
- return tracer;
- } catch (Exception e) {
- return io.opentelemetry.api.GlobalOpenTelemetry.get().getTracer(INSTRUMENTATION_NAME);
- }
- }
-
- static SpanBuilder createSpanBuilder(String model, String operationType, String spanKind) {
- Tracer tracer = getTracer();
- String spanName = operationType + " " + (model != null ? model : "unknown");
- SpanBuilder builder = tracer.spanBuilder(spanName)
- .setSpanKind(SpanKind.CLIENT)
- .setAttribute("gen_ai.system", "openai")
- .setAttribute("gen_ai.operation.name", operationType)
- .setAttribute("gen_ai.provider.name", "openai");
-
- if (spanKind != null) {
- builder.setAttribute("langsmith.span.kind", spanKind);
- }
-
- return builder;
- }
-
- static SpanBuilder createSpanBuilder(String model, String operationType) {
- return createSpanBuilder(model, operationType, "llm");
- }
-
- static void setRequestAttributes(Span span, String model) {
- if (model != null) {
- span.setAttribute("gen_ai.request.model", model);
- }
- }
-
- static void setRequestParameters(Span span, Double temperature, Double topP, Long maxTokens) {
- if (temperature != null) {
- span.setAttribute("gen_ai.request.temperature", temperature);
- }
- if (topP != null) {
- span.setAttribute("gen_ai.request.top_p", topP);
- }
- if (maxTokens != null) {
- span.setAttribute("gen_ai.request.max_tokens", maxTokens);
- }
- }
-
- static void setInputMessages(Span span, String messagesJson) {
- if (messagesJson != null) {
- span.setAttribute("gen_ai.input.messages", messagesJson);
- }
- }
-
- static void setOutputMessages(Span span, String messagesJson) {
- if (messagesJson != null) {
- span.setAttribute("gen_ai.output.messages", messagesJson);
- }
- }
-
- static void setResponseAttributes(Span span, Long inputTokens, Long outputTokens, Long totalTokens) {
- if (inputTokens != null) {
- span.setAttribute("gen_ai.usage.input_tokens", inputTokens);
- }
- if (outputTokens != null) {
- span.setAttribute("gen_ai.usage.output_tokens", outputTokens);
- }
- if (totalTokens != null) {
- span.setAttribute("gen_ai.usage.total_tokens", totalTokens);
- }
- }
-
- static void setResponseMetadata(Span span, String responseModel, String finishReason) {
- if (responseModel != null) {
- span.setAttribute("gen_ai.response.model", responseModel);
- }
- if (finishReason != null) {
- span.setAttribute("gen_ai.response.finish_reason", finishReason);
- }
- }
-
- static void recordException(Span span, Throwable exception) {
- span.recordException(exception);
- span.setAttribute("error", true);
- }
-
- static String escapeJsonString(String str) {
- if (str == null) {
- return "";
- }
- return str.replace("\\", "\\\\")
- .replace("\"", "\\\"")
- .replace("\n", "\\n")
- .replace("\r", "\\r")
- .replace("\t", "\\t");
- }
-}
diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedChatService.java b/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedChatService.java
deleted file mode 100644
index d6dc0146..00000000
--- a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedChatService.java
+++ /dev/null
@@ -1,1009 +0,0 @@
-package com.langchain.smith.wrappers.openai;
-
-import com.openai.core.ClientOptions;
-import com.openai.core.RequestOptions;
-import com.openai.core.http.StreamResponse;
-import com.openai.models.chat.completions.ChatCompletion;
-import com.openai.models.chat.completions.ChatCompletionChunk;
-import com.openai.models.chat.completions.ChatCompletionCreateParams;
-import com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall;
-import com.openai.models.chat.completions.ChatCompletionMessageToolCall;
-import com.openai.models.chat.completions.StructuredChatCompletion;
-import com.openai.models.chat.completions.StructuredChatCompletionCreateParams;
-import com.openai.services.blocking.ChatService;
-import com.openai.services.blocking.chat.ChatCompletionService;
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.context.Scope;
-import java.util.function.Consumer;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Wrapped ChatService that adds OpenTelemetry tracing to chat completion
- * operations.
- */
-class WrappedChatService implements ChatService {
-
- private final ChatService delegate;
-
- WrappedChatService(ChatService delegate) {
- this.delegate = delegate;
- }
-
- @Override
- public ChatService.WithRawResponse withRawResponse() {
- return delegate.withRawResponse();
- }
-
- @Override
- public ChatService withOptions(Consumer options) {
- return new WrappedChatService(delegate.withOptions(options));
- }
-
- @Override
- public ChatCompletionService completions() {
- return new WrappedChatCompletionService(delegate.completions());
- }
-
- /**
- * Wrapped ChatCompletionService that adds tracing to create operations.
- */
- private static class WrappedChatCompletionService implements ChatCompletionService {
- private static final Logger logger = LoggerFactory.getLogger(WrappedChatCompletionService.class);
-
- private final ChatCompletionService delegate;
-
- WrappedChatCompletionService(ChatCompletionService delegate) {
- this.delegate = delegate;
- }
-
- @Override
- public ChatCompletionService.WithRawResponse withRawResponse() {
- return delegate.withRawResponse();
- }
-
- @Override
- public ChatCompletionService withOptions(Consumer options) {
- return new WrappedChatCompletionService(delegate.withOptions(options));
- }
-
- @Override
- public com.openai.services.blocking.chat.completions.MessageService messages() {
- return delegate.messages();
- }
-
- @Override
- public ChatCompletion create(ChatCompletionCreateParams params) {
- return create(params, null);
- }
-
- @Override
- public ChatCompletion create(ChatCompletionCreateParams params, RequestOptions requestOptions) {
- // Extract model from params for span naming
- String model = params.model() != null ? params.model().toString() : null;
-
- Span span = TracingUtils.createSpanBuilder(model, "chat").startSpan();
-
- // Debug: Check if span is recording (not a noop span)
- if (logger.isDebugEnabled()) {
- boolean isRecording = span.isRecording();
- logger.debug(
- "[WrappedChatService] Created span: {}, isRecording: {}, traceId: {}",
- span.getSpanContext().getSpanId(),
- isRecording,
- span.getSpanContext().getTraceId());
- }
-
- try (Scope scope = span.makeCurrent()) {
- // Set experiment context attributes if present
- setExperimentContextAttributes(span);
-
- // Set request attributes (core attributes already set on builder)
- TracingUtils.setRequestAttributes(span, model);
-
- // Set request parameters if available (handle Optional types)
- Double temperature = params.temperature().orElse(null);
- Double topP = params.topP().orElse(null);
- Long maxTokens = params.maxCompletionTokens().orElse(null);
- TracingUtils.setRequestParameters(span, temperature, topP, maxTokens);
-
- // Capture input messages in JSON format
- String inputMessagesJson = formatInputMessages(params);
- TracingUtils.setInputMessages(span, inputMessagesJson);
-
- // Extract prompt (first user message) for gen_ai.prompt attribute
- String prompt = extractPromptFromParams(params);
- if (prompt != null && !prompt.isEmpty()) {
- span.setAttribute(io.opentelemetry.api.common.AttributeKey.stringKey("gen_ai.prompt"), prompt);
- }
-
- ChatCompletion result;
- // If requestOptions is null, use the single-parameter version
- if (requestOptions == null) {
- result = delegate.create(params);
- } else {
- result = delegate.create(params, requestOptions);
- }
-
- // Extract response model and finish reason
- String responseModel = result.model();
- String finishReason = !result.choices().isEmpty()
- ? result.choices().get(0).finishReason().toString()
- : "stop";
- TracingUtils.setResponseMetadata(span, responseModel, finishReason);
-
- // Capture output messages in JSON format
- String outputMessagesJson = formatOutputMessages(result);
- TracingUtils.setOutputMessages(span, outputMessagesJson);
-
- // Extract completion (assistant response) for gen_ai.completion attribute
- String completion = extractCompletionFromResult(result);
- if (completion != null && !completion.isEmpty()) {
- span.setAttribute(
- io.opentelemetry.api.common.AttributeKey.stringKey("gen_ai.completion"), completion);
- }
-
- // Extract usage information from result
- result.usage().ifPresent(usage -> {
- TracingUtils.setResponseAttributes(
- span, (long) usage.promptTokens(), (long) usage.completionTokens(), (long)
- usage.totalTokens());
- });
-
- // Tool call spans are not created automatically here.
- // Users should create tool execution spans manually when executing tools
- // to capture both input (arguments) and output (result).
- // createToolCallSpans(result, span);
-
- return result;
- } catch (Exception e) {
- TracingUtils.recordException(span, e);
- throw e;
- } finally {
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[WrappedChatService] Ending span: {}",
- span.getSpanContext().getSpanId());
- }
- span.end();
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[WrappedChatService] Span ended: {}",
- span.getSpanContext().getSpanId());
- }
- }
- }
-
- /**
- * Sets experiment context attributes on the span if they are present.
- * This includes reference example ID, session ID, and metadata.
- *
- * @param span the span to set attributes on
- */
- private void setExperimentContextAttributes(Span span) {
- // Set reference example ID if present
- ExperimentContext.current()
- .getReferenceExampleId()
- .filter(id -> !id.isEmpty())
- .ifPresent(id -> span.setAttribute("langsmith.reference_example_id", id));
-
- // Set session ID (experiment ID) if present
- // This is critical for linking runs to experiments in the dataset's Experiments tab
- ExperimentContext.current()
- .getSessionId()
- .filter(id -> !id.isEmpty())
- .ifPresent(id -> span.setAttribute("langsmith.trace.session_id", id));
-
- // Set custom metadata
- java.util.Map metadata = ExperimentContext.current().getMetadata();
- for (java.util.Map.Entry entry : metadata.entrySet()) {
- span.setAttribute("langsmith.metadata." + entry.getKey(), entry.getValue());
- }
- }
-
- /**
- * Formats input messages from ChatCompletionCreateParams as a JSON array
- * string.
- *
- *
- * Properly formats messages as JSON with role and content, following
- * LangSmith conventions.
- */
- private String formatInputMessages(ChatCompletionCreateParams params) {
- if (params.messages().isEmpty()) {
- return "[]";
- }
-
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[formatInputMessages] Processing {} message(s)",
- params.messages().size());
- }
-
- StringBuilder json = new StringBuilder("[");
- boolean first = true;
- for (com.openai.models.chat.completions.ChatCompletionMessageParam messageParam : params.messages()) {
- if (!first) {
- json.append(",");
- }
- first = false;
-
- json.append("{");
-
- String role = null;
- String content = null;
-
- // Try to extract role and content based on message type
- // The OpenAI SDK uses different message types (UserMessage, SystemMessage,
- // etc.)
- String className = messageParam.getClass().getSimpleName();
- String fullClassName = messageParam.getClass().getName();
-
- // Debug logging
- if (logger.isDebugEnabled()) {
- logger.debug("[formatInputMessages] Processing message: {}", fullClassName);
- java.lang.reflect.Method[] allMethods =
- messageParam.getClass().getMethods();
- logger.debug(
- "[formatInputMessages] Available methods: {}",
- java.util.Arrays.stream(allMethods)
- .map(m -> m.getName() + "(" + m.getParameterCount() + ")")
- .collect(java.util.stream.Collectors.joining(", ")));
- }
-
- // ChatCompletionMessageParam is a union type - check type FIRST before calling
- // as*() methods
- // This prevents InvocationTargetException when calling asUser() on non-user
- // messages
- Object actualMessage = null;
-
- try {
- // Use type checking methods first to avoid exceptions
- java.lang.reflect.Method isUserMethod =
- messageParam.getClass().getMethod("isUser");
- java.lang.reflect.Method isSystemMethod =
- messageParam.getClass().getMethod("isSystem");
- java.lang.reflect.Method isAssistantMethod =
- messageParam.getClass().getMethod("isAssistant");
- java.lang.reflect.Method isToolMethod =
- messageParam.getClass().getMethod("isTool");
-
- boolean isUser = (Boolean) isUserMethod.invoke(messageParam);
- boolean isSystem = (Boolean) isSystemMethod.invoke(messageParam);
- boolean isAssistant = (Boolean) isAssistantMethod.invoke(messageParam);
- boolean isTool = (Boolean) isToolMethod.invoke(messageParam);
-
- if (isUser) {
- java.lang.reflect.Method asUserMethod =
- messageParam.getClass().getMethod("asUser");
- actualMessage = asUserMethod.invoke(messageParam);
- role = "user";
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[formatInputMessages] Found user message: {}",
- actualMessage.getClass().getName());
- }
- } else if (isSystem) {
- java.lang.reflect.Method asSystemMethod =
- messageParam.getClass().getMethod("asSystem");
- actualMessage = asSystemMethod.invoke(messageParam);
- role = "system";
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[formatInputMessages] Found system message: {}",
- actualMessage.getClass().getName());
- }
- } else if (isAssistant) {
- java.lang.reflect.Method asAssistantMethod =
- messageParam.getClass().getMethod("asAssistant");
- actualMessage = asAssistantMethod.invoke(messageParam);
- role = "assistant";
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[formatInputMessages] Found assistant message: {}",
- actualMessage.getClass().getName());
- }
- } else if (isTool) {
- java.lang.reflect.Method asToolMethod =
- messageParam.getClass().getMethod("asTool");
- actualMessage = asToolMethod.invoke(messageParam);
- role = "tool";
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[formatInputMessages] Found tool message: {}",
- actualMessage.getClass().getName());
- }
- }
-
- // Now get content from the actual message object
- if (actualMessage != null) {
- try {
- java.lang.reflect.Method contentMethod =
- actualMessage.getClass().getMethod("content");
- Object contentResult = contentMethod.invoke(actualMessage);
-
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[formatInputMessages] content() returned: {}",
- contentResult != null
- ? contentResult.getClass().getName()
- : "null");
- }
-
- // Content might be a Content object with text() method
- if (contentResult != null) {
- try {
- java.lang.reflect.Method textMethod =
- contentResult.getClass().getMethod("text");
- Object textResult = textMethod.invoke(contentResult);
- if (textResult instanceof java.util.Optional) {
- @SuppressWarnings("unchecked")
- java.util.Optional textOpt = (java.util.Optional) textResult;
- if (textOpt.isPresent()) {
- content = textOpt.get();
- }
- } else if (textResult instanceof String) {
- content = (String) textResult;
- }
- } catch (NoSuchMethodException e) {
- // Content might be a String directly
- if (contentResult instanceof String) {
- content = (String) contentResult;
- } else if (contentResult instanceof java.util.Optional) {
- @SuppressWarnings("unchecked")
- java.util.Optional contentOpt =
- (java.util.Optional) contentResult;
- if (contentOpt.isPresent()) {
- content = contentOpt.get();
- }
- }
- }
- }
- } catch (NoSuchMethodException e) {
- if (logger.isDebugEnabled()) {
- logger.debug("[formatInputMessages] No content() method on actual message");
- }
- }
- }
- } catch (Exception e) {
- if (logger.isDebugEnabled()) {
- logger.debug("[formatInputMessages] Error accessing message", e);
- }
- }
-
- // Role should already be set from asUser/asSystem/asAssistant above
- // If not, try to get it from the message
- if (role == null) {
- try {
- java.lang.reflect.Method roleMethod =
- messageParam.getClass().getMethod("role");
- Object roleResult = roleMethod.invoke(messageParam);
- if (roleResult != null) {
- role = roleResult.toString().toLowerCase();
- }
- } catch (NoSuchMethodException e) {
- // Fallback: determine role from class name
- if (className.contains("User") || fullClassName.contains("User")) {
- role = "user";
- } else if (className.contains("System") || fullClassName.contains("System")) {
- role = "system";
- } else if (className.contains("Assistant") || fullClassName.contains("Assistant")) {
- role = "assistant";
- } else if (className.contains("Tool") || fullClassName.contains("Tool")) {
- role = "tool";
- }
- } catch (Exception e) {
- if (logger.isDebugEnabled()) {
- logger.debug("[formatInputMessages] Error calling role()", e);
- }
- // Fallback to class name
- if (className.contains("User") || fullClassName.contains("User")) {
- role = "user";
- } else if (className.contains("System") || fullClassName.contains("System")) {
- role = "system";
- } else if (className.contains("Assistant") || fullClassName.contains("Assistant")) {
- role = "assistant";
- } else if (className.contains("Tool") || fullClassName.contains("Tool")) {
- role = "tool";
- }
- }
- }
-
- // Final fallback: try toString parsing
- if (content == null) {
- String messageStr = messageParam.toString();
- if (logger.isDebugEnabled()) {
- logger.debug("[formatInputMessages] toString(): {}", messageStr);
- }
-
- // For tool messages, look for content=Content{text={...}} pattern
- if (role != null && role.equals("tool")) {
- // Extract JSON content from tool messages:
- // content=Content{text={"key":"value"}}
- java.util.regex.Pattern toolContentPattern =
- java.util.regex.Pattern.compile("text=\\{([^}]+)\\}");
- java.util.regex.Matcher toolMatcher = toolContentPattern.matcher(messageStr);
- if (toolMatcher.find()) {
- content = "{" + toolMatcher.group(1) + "}";
- } else {
- // Try simpler pattern: text="..."
- java.util.regex.Pattern simplePattern =
- java.util.regex.Pattern.compile("text=[\"']([^\"']+)[\"']");
- java.util.regex.Matcher simpleMatcher = simplePattern.matcher(messageStr);
- if (simpleMatcher.find()) {
- content = simpleMatcher.group(1);
- }
- }
- }
-
- // Improved extraction: try multiple patterns
- if (content == null) {
- // Pattern 1: content="..."
- int contentIdx = messageStr.indexOf("content=");
- if (contentIdx >= 0) {
- // Look for Content{text="..."} pattern
- int textIdx = messageStr.indexOf("text=", contentIdx);
- if (textIdx > contentIdx) {
- int start = messageStr.indexOf("\"", textIdx);
- if (start >= 0) {
- // Find matching closing quote, handling escaped quotes
- int end = start + 1;
- while (end < messageStr.length() && messageStr.charAt(end) != '"') {
- if (messageStr.charAt(end) == '\\') {
- end += 2; // Skip escaped character
- } else {
- end++;
- }
- }
- if (end < messageStr.length()) {
- content = messageStr.substring(start + 1, end);
- }
- }
- }
-
- // Fallback to simple quote matching
- if (content == null) {
- int start = messageStr.indexOf("\"", contentIdx);
- if (start >= 0) {
- int end = messageStr.indexOf("\"", start + 1);
- if (end > start) {
- content = messageStr.substring(start + 1, end);
- }
- } else {
- // Try single quotes
- start = messageStr.indexOf("'", contentIdx);
- if (start >= 0) {
- int end = messageStr.indexOf("'", start + 1);
- if (end > start) {
- content = messageStr.substring(start + 1, end);
- }
- }
- }
- }
- }
- }
-
- // Pattern 2: Look for content in JSON-like format
- if (content == null) {
- java.util.regex.Pattern pattern =
- java.util.regex.Pattern.compile("content[=:]\\s*[\"']([^\"']+)[\"']");
- java.util.regex.Matcher matcher = pattern.matcher(messageStr);
- if (matcher.find()) {
- content = matcher.group(1);
- }
- }
-
- // Pattern 3: If still null, try to extract from the end of the string
- if (content == null && messageStr.length() > 0) {
- // Last resort: try to find any quoted string that might be content
- java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("[\"']([^\"']+)[\"']");
- java.util.regex.Matcher matcher = pattern.matcher(messageStr);
- if (matcher.find()) {
- String potentialContent = matcher.group(1);
- // Only use if it looks like actual content (not a class name or role)
- if (!potentialContent.contains("com.openai")
- && !potentialContent.equals("user")
- && !potentialContent.equals("system")
- && !potentialContent.equals("assistant")
- && !potentialContent.equals("tool")
- && potentialContent.length() > 0) {
- content = potentialContent;
- }
- }
- }
- }
-
- // Build JSON
- if (role != null) {
- json.append("\"role\":\"").append(role).append("\"");
- }
- if (content != null) {
- if (role != null) {
- json.append(",");
- }
- json.append("\"content\":\"")
- .append(TracingUtils.escapeJsonString(content))
- .append("\"");
- }
-
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[formatInputMessages] Final: role={}, content={}",
- role,
- content != null ? content.substring(0, Math.min(50, content.length())) : "null");
- }
-
- json.append("}");
- }
- json.append("]");
-
- String result = json.toString();
- if (logger.isDebugEnabled()) {
- logger.debug("[formatInputMessages] Final JSON: {}", result);
- }
- return result;
- }
-
- /**
- * Creates tool call spans for any tool calls detected in the chat completion
- * response.
- *
- * @param completion the chat completion response
- * @param parentSpan the parent span (the chat completion span)
- */
- private void createToolCallSpans(ChatCompletion completion, Span parentSpan) {
- if (completion.choices() == null || completion.choices().isEmpty()) {
- return;
- }
-
- for (com.openai.models.chat.completions.ChatCompletion.Choice choice : completion.choices()) {
- com.openai.models.chat.completions.ChatCompletionMessage message = choice.message();
-
- // Check if message has tool calls
- java.util.Optional> toolCallsOpt = message.toolCalls();
- if (toolCallsOpt.isPresent()) {
- java.util.List toolCalls = toolCallsOpt.get();
- for (ChatCompletionMessageToolCall toolCall : toolCalls) {
- // Check if it's a function tool call
- if (toolCall.isFunction()) {
- ChatCompletionMessageFunctionToolCall functionToolCall = toolCall.asFunction();
- createToolCallSpan(functionToolCall, parentSpan);
- }
- // Note: Custom tool calls are not yet supported
- }
- }
- }
- }
-
- /**
- * Creates a single tool call span from a function tool call object.
- *
- * @param functionToolCall the function tool call object from the OpenAI SDK
- * @param parentSpan the parent span (the chat completion span)
- */
- private void createToolCallSpan(ChatCompletionMessageFunctionToolCall functionToolCall, Span parentSpan) {
- try {
- io.opentelemetry.api.OpenTelemetry openTelemetry = io.opentelemetry.api.GlobalOpenTelemetry.get();
- io.opentelemetry.api.trace.Tracer tracer = openTelemetry.getTracer("langsmith-java-otel-wrappers");
-
- // Extract tool call information directly from the SDK objects
- String toolCallId = functionToolCall.id();
- com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall.Function function =
- functionToolCall.function();
- String toolName = function.name();
- String toolArguments = function.arguments();
-
- // Create span name
- String spanName = toolName != null ? "tool_call " + toolName : "tool_call";
-
- // Create tool call span as a child of the parent span
- io.opentelemetry.api.trace.Span toolCallSpan = tracer.spanBuilder(spanName)
- .setSpanKind(io.opentelemetry.api.trace.SpanKind.CLIENT)
- .setAttribute("gen_ai.operation.name", "tool_call")
- .setAttribute("langsmith.span.kind", "tool")
- .setAttribute("gen_ai.system", "openai")
- .setAttribute("gen_ai.provider.name", "openai")
- .startSpan();
-
- try (io.opentelemetry.context.Scope toolScope = toolCallSpan.makeCurrent()) {
- // Set tool call attributes
- if (toolCallId != null) {
- toolCallSpan.setAttribute("gen_ai.tool.call.id", toolCallId);
- }
- if (toolName != null) {
- toolCallSpan.setAttribute("gen_ai.tool.name", toolName);
- toolCallSpan.setAttribute("langsmith.trace.name", "Tool Call: " + toolName);
- }
- if (toolArguments != null && !toolArguments.isEmpty()) {
- toolCallSpan.setAttribute("gen_ai.tool.arguments", toolArguments);
- // Set tool arguments as input/prompt for LangSmith visibility
- toolCallSpan.setAttribute("gen_ai.prompt", toolArguments);
- }
-
- if (logger.isDebugEnabled()) {
- logger.debug(
- "[WrappedChatService] Created tool call span: {}, tool={}, arguments={}, parent={}",
- toolCallSpan.getSpanContext().getSpanId(),
- toolName,
- toolArguments,
- parentSpan.getSpanContext().getSpanId());
- }
-
- // Note: Tool call result would be set when the tool is actually executed
- // This span represents the tool call request, not the execution
- } finally {
- toolCallSpan.end();
- }
- } catch (Exception e) {
- if (logger.isDebugEnabled()) {
- logger.debug("[WrappedChatService] Error creating tool call span", e);
- }
- }
- }
-
- /**
- * Formats output messages from ChatCompletion as a JSON array string.
- */
- private String formatOutputMessages(ChatCompletion completion) {
- if (completion.choices() == null || completion.choices().isEmpty()) {
- return "[]";
- }
-
- StringBuilder json = new StringBuilder("[");
- boolean first = true;
- for (com.openai.models.chat.completions.ChatCompletion.Choice choice : completion.choices()) {
- if (!first) {
- json.append(",");
- }
- first = false;
-
- com.openai.models.chat.completions.ChatCompletionMessage message = choice.message();
- json.append("{");
-
- // Add role
- json.append("\"role\":\"assistant\"");
-
- // Add content if present
- message.content().ifPresent(content -> {
- json.append(",\"content\":\"")
- .append(TracingUtils.escapeJsonString(content))
- .append("\"");
- });
-
- // Add tool_calls if present
- message.toolCalls().ifPresent(toolCalls -> {
- if (!toolCalls.isEmpty()) {
- json.append(",\"tool_calls\":[");
- boolean firstToolCall = true;
- for (ChatCompletionMessageToolCall toolCall : toolCalls) {
- // Only process function tool calls (other types not yet supported)
- if (!toolCall.isFunction()) {
- continue;
- }
-
- if (!firstToolCall) {
- json.append(",");
- }
- firstToolCall = false;
-
- ChatCompletionMessageFunctionToolCall functionToolCall = toolCall.asFunction();
- json.append("{");
- json.append("\"id\":\"")
- .append(TracingUtils.escapeJsonString(functionToolCall.id()))
- .append("\"");
- json.append(",\"type\":\"function\"");
-
- com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall.Function function =
- functionToolCall.function();
- json.append(",\"function\":{");
- json.append("\"name\":\"")
- .append(TracingUtils.escapeJsonString(function.name()))
- .append("\"");
- json.append(",\"arguments\":\"")
- .append(TracingUtils.escapeJsonString(function.arguments()))
- .append("\"");
- json.append("}");
- json.append("}");
- }
- json.append("]");
- }
- });
-
- json.append("}");
- }
- json.append("]");
-
- return json.toString();
- }
-
- /**
- * Extracts the prompt text from the first user message in the params.
- *
- * @param params the chat completion create params
- * @return the prompt text, or null if not found
- */
- private String extractPromptFromParams(ChatCompletionCreateParams params) {
- if (params.messages().isEmpty()) {
- return null;
- }
-
- // Find the first user message
- for (com.openai.models.chat.completions.ChatCompletionMessageParam messageParam : params.messages()) {
- if (messageParam.isUser()) {
- com.openai.models.chat.completions.ChatCompletionUserMessageParam userMessage =
- messageParam.asUser();
- // Try to get content from the user message
- if (userMessage.content() != null) {
- // Content can be a string or a list of content parts
- Object content = userMessage.content();
- if (content instanceof String) {
- return (String) content;
- } else if (content instanceof java.util.List) {
- @SuppressWarnings("unchecked")
- java.util.List> contentList = (java.util.List>) content;
- if (!contentList.isEmpty()) {
- Object firstContent = contentList.get(0);
- if (firstContent instanceof String) {
- return (String) firstContent;
- }
- }
- }
- }
- }
- }
- return null;
- }
-
- /**
- * Extracts the completion text from the assistant message in the result.
- *
- * @param result the chat completion result
- * @return the completion text, or null if not found
- */
- private String extractCompletionFromResult(ChatCompletion result) {
- if (result.choices() == null || result.choices().isEmpty()) {
- return null;
- }
-
- com.openai.models.chat.completions.ChatCompletionMessage message =
- result.choices().get(0).message();
- return message.content().orElse(null);
- }
-
- @Override
- public StructuredChatCompletion create(StructuredChatCompletionCreateParams params) {
- return create(params, null);
- }
-
- @Override
- public StructuredChatCompletion create(
- StructuredChatCompletionCreateParams params, RequestOptions requestOptions) {
- // Get model from the underlying params - StructuredChatCompletionCreateParams
- // wraps ChatCompletionCreateParams
- String model = params != null
- && params.rawParams() != null
- && params.rawParams().model() != null
- ? params.rawParams().model().toString()
- : null;
-
- Span span = TracingUtils.createSpanBuilder(model, "chat").startSpan();
-
- try (Scope scope = span.makeCurrent()) {
- // Set experiment context attributes if present
- setExperimentContextAttributes(span);
-
- // Set request attributes (core attributes already set on builder)
- TracingUtils.setRequestAttributes(span, model);
-
- // Set request parameters if available
- if (params.rawParams() != null) {
- Double temperature = params.rawParams().temperature().orElse(null);
- Double topP = params.rawParams().topP().orElse(null);
- Long maxTokens = params.rawParams().maxCompletionTokens().orElse(null);
- TracingUtils.setRequestParameters(span, temperature, topP, maxTokens);
-
- // Capture input messages in JSON format
- String inputMessagesJson = formatInputMessages(params.rawParams());
- TracingUtils.setInputMessages(span, inputMessagesJson);
- }
-
- StructuredChatCompletion result;
- // If requestOptions is null, use the single-parameter version
- if (requestOptions == null) {
- result = delegate.create(params);
- } else {
- result = delegate.create(params, requestOptions);
- }
-
- // For structured completions, we'll just set basic model info
- // The actual structured output is in the parsed result
- TracingUtils.setResponseMetadata(span, model, null);
-
- result.usage().ifPresent(usage -> {
- TracingUtils.setResponseAttributes(
- span, (long) usage.promptTokens(), (long) usage.completionTokens(), (long)
- usage.totalTokens());
- });
-
- return result;
- } catch (Exception e) {
- TracingUtils.recordException(span, e);
- throw e;
- } finally {
- span.end();
- }
- }
-
- @Override
- public StreamResponse createStreaming(ChatCompletionCreateParams params) {
- return createStreaming(params, null);
- }
-
- @Override
- public StreamResponse createStreaming(
- ChatCompletionCreateParams params, RequestOptions requestOptions) {
- String model = params.model() != null ? params.model().toString() : null;
-
- Span span = TracingUtils.createSpanBuilder(model, "chat").startSpan();
-
- try (Scope scope = span.makeCurrent()) {
- // Set experiment context attributes if present
- setExperimentContextAttributes(span);
-
- // Set request attributes (core attributes already set on builder)
- TracingUtils.setRequestAttributes(span, model);
- span.setAttribute("gen_ai.streaming", true);
-
- // Set request parameters if available (handle Optional types)
- Double temperature = params.temperature().orElse(null);
- Double topP = params.topP().orElse(null);
- Long maxTokens = params.maxCompletionTokens().orElse(null);
- TracingUtils.setRequestParameters(span, temperature, topP, maxTokens);
-
- // Capture input messages in JSON format
- String inputMessagesJson = formatInputMessages(params);
- TracingUtils.setInputMessages(span, inputMessagesJson);
-
- // For streaming, we can't easily extract usage info, so we'll just mark it as
- // streaming
- StreamResponse result;
- // If requestOptions is null, use the single-parameter version
- if (requestOptions == null) {
- result = delegate.createStreaming(params);
- } else {
- result = delegate.createStreaming(params, requestOptions);
- }
-
- // Note: For streaming, the span will end immediately
- // This is a simplified implementation - in production you might want to
- // wrap the stream to collect the full response
- return result;
- } catch (Exception e) {
- TracingUtils.recordException(span, e);
- throw e;
- } finally {
- span.end();
- }
- }
-
- // Delegate other methods without tracing (for now)
- @Override
- public ChatCompletion retrieve(String completionId) {
- return delegate.retrieve(completionId);
- }
-
- @Override
- public ChatCompletion retrieve(
- String completionId, com.openai.models.chat.completions.ChatCompletionRetrieveParams params) {
- return delegate.retrieve(completionId, params);
- }
-
- @Override
- public ChatCompletion retrieve(String completionId, RequestOptions requestOptions) {
- return delegate.retrieve(completionId, requestOptions);
- }
-
- @Override
- public ChatCompletion retrieve(
- String completionId,
- com.openai.models.chat.completions.ChatCompletionRetrieveParams params,
- RequestOptions requestOptions) {
- return delegate.retrieve(completionId, params, requestOptions);
- }
-
- @Override
- public ChatCompletion retrieve(com.openai.models.chat.completions.ChatCompletionRetrieveParams params) {
- return delegate.retrieve(params);
- }
-
- @Override
- public ChatCompletion retrieve(
- com.openai.models.chat.completions.ChatCompletionRetrieveParams params, RequestOptions requestOptions) {
- return delegate.retrieve(params, requestOptions);
- }
-
- @Override
- public ChatCompletion update(
- String completionId, com.openai.models.chat.completions.ChatCompletionUpdateParams params) {
- return delegate.update(completionId, params);
- }
-
- @Override
- public ChatCompletion update(
- String completionId,
- com.openai.models.chat.completions.ChatCompletionUpdateParams params,
- RequestOptions requestOptions) {
- return delegate.update(completionId, params, requestOptions);
- }
-
- @Override
- public ChatCompletion update(com.openai.models.chat.completions.ChatCompletionUpdateParams params) {
- return delegate.update(params);
- }
-
- @Override
- public ChatCompletion update(
- com.openai.models.chat.completions.ChatCompletionUpdateParams params, RequestOptions requestOptions) {
- return delegate.update(params, requestOptions);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionListPage list() {
- return delegate.list();
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionListPage list(RequestOptions requestOptions) {
- return delegate.list(requestOptions);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionListPage list(
- com.openai.models.chat.completions.ChatCompletionListParams params) {
- return delegate.list(params);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionListPage list(
- com.openai.models.chat.completions.ChatCompletionListParams params, RequestOptions requestOptions) {
- return delegate.list(params, requestOptions);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionDeleted delete(String completionId) {
- return delegate.delete(completionId);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionDeleted delete(
- String completionId, RequestOptions requestOptions) {
- return delegate.delete(completionId, requestOptions);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionDeleted delete(
- String completionId, com.openai.models.chat.completions.ChatCompletionDeleteParams params) {
- return delegate.delete(completionId, params);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionDeleted delete(
- String completionId,
- com.openai.models.chat.completions.ChatCompletionDeleteParams params,
- RequestOptions requestOptions) {
- return delegate.delete(completionId, params, requestOptions);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionDeleted delete(
- com.openai.models.chat.completions.ChatCompletionDeleteParams params) {
- return delegate.delete(params);
- }
-
- @Override
- public com.openai.models.chat.completions.ChatCompletionDeleted delete(
- com.openai.models.chat.completions.ChatCompletionDeleteParams params, RequestOptions requestOptions) {
- return delegate.delete(params, requestOptions);
- }
- }
-}
diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.java b/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.java
deleted file mode 100644
index da7d19ad..00000000
--- a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.java
+++ /dev/null
@@ -1,290 +0,0 @@
-package com.langchain.smith.wrappers.openai;
-
-import com.openai.client.OpenAIClient;
-import com.openai.client.okhttp.OpenAIOkHttpClient;
-import com.openai.core.ClientOptions;
-import java.util.function.Consumer;
-
-/**
- * Wrapped OpenAI client that maintains the same developer experience as the
- * original client
- * while adding LangSmith tracing capabilities.
- *
- *
- * This wrapper delegates all calls to the underlying OpenAI client, allowing
- * all
- * configuration options and methods to work exactly as they would with the
- * original client.
- */
-public class WrappedOpenAIClient implements OpenAIClient {
-
- private final OpenAIClient delegate;
-
- /**
- * Creates a new wrapped client that delegates to the provided client.
- *
- * @param delegate the underlying OpenAI client to wrap
- */
- public WrappedOpenAIClient(OpenAIClient delegate) {
- if (delegate == null) {
- throw new IllegalArgumentException("Delegate client cannot be null");
- }
- this.delegate = delegate;
- }
-
- /**
- * Gets the underlying delegate client.
- *
- * @return the wrapped OpenAI client
- */
- public OpenAIClient getDelegate() {
- return delegate;
- }
-
- @Override
- public com.openai.client.OpenAIClientAsync async() {
- return delegate.async();
- }
-
- @Override
- public OpenAIClient.WithRawResponse withRawResponse() {
- return delegate.withRawResponse();
- }
-
- @Override
- public OpenAIClient withOptions(Consumer options) {
- return delegate.withOptions(options);
- }
-
- @Override
- public com.openai.services.blocking.CompletionService completions() {
- return delegate.completions();
- }
-
- @Override
- public com.openai.services.blocking.ChatService chat() {
- return new WrappedChatService(delegate.chat());
- }
-
- @Override
- public com.openai.services.blocking.EmbeddingService embeddings() {
- return delegate.embeddings();
- }
-
- @Override
- public com.openai.services.blocking.FileService files() {
- return delegate.files();
- }
-
- @Override
- public com.openai.services.blocking.ImageService images() {
- return delegate.images();
- }
-
- @Override
- public com.openai.services.blocking.AudioService audio() {
- return delegate.audio();
- }
-
- @Override
- public com.openai.services.blocking.ModerationService moderations() {
- return delegate.moderations();
- }
-
- @Override
- public com.openai.services.blocking.ModelService models() {
- return delegate.models();
- }
-
- @Override
- public com.openai.services.blocking.FineTuningService fineTuning() {
- return delegate.fineTuning();
- }
-
- @Override
- public com.openai.services.blocking.GraderService graders() {
- return delegate.graders();
- }
-
- @Override
- public com.openai.services.blocking.VectorStoreService vectorStores() {
- return delegate.vectorStores();
- }
-
- @Override
- public com.openai.services.blocking.WebhookService webhooks() {
- return delegate.webhooks();
- }
-
- @Override
- public com.openai.services.blocking.BetaService beta() {
- return delegate.beta();
- }
-
- @Override
- public com.openai.services.blocking.BatchService batches() {
- return delegate.batches();
- }
-
- @Override
- public com.openai.services.blocking.UploadService uploads() {
- return delegate.uploads();
- }
-
- @Override
- public com.openai.services.blocking.ResponseService responses() {
- return new WrappedResponseService(delegate.responses());
- }
-
- @Override
- public com.openai.services.blocking.RealtimeService realtime() {
- return delegate.realtime();
- }
-
- @Override
- public com.openai.services.blocking.ConversationService conversations() {
- return delegate.conversations();
- }
-
- @Override
- public com.openai.services.blocking.EvalService evals() {
- return delegate.evals();
- }
-
- @Override
- public com.openai.services.blocking.ContainerService containers() {
- return delegate.containers();
- }
-
- @Override
- public com.openai.services.blocking.VideoService videos() {
- return delegate.videos();
- }
-
- @Override
- public void close() {
- delegate.close();
- }
-
- /**
- * Builder for creating wrapped OpenAI clients with the same configuration
- * options
- * as the original client builder.
- */
- public static class Builder {
- private final OpenAIOkHttpClient.Builder delegateBuilder;
-
- /**
- * Creates a new builder that wraps the OpenAI client builder.
- */
- public Builder() {
- this.delegateBuilder = OpenAIOkHttpClient.builder();
- }
-
- /**
- * Creates a new builder that wraps the OpenAI client builder, starting from
- * environment variables.
- *
- * @return this builder for method chaining
- */
- public Builder fromEnv() {
- delegateBuilder.fromEnv();
- return this;
- }
-
- /**
- * Sets the API key.
- *
- * @param apiKey the OpenAI API key
- * @return this builder for method chaining
- */
- public Builder apiKey(String apiKey) {
- delegateBuilder.apiKey(apiKey);
- return this;
- }
-
- /**
- * Sets the organization ID.
- *
- * @param organization the organization ID
- * @return this builder for method chaining
- */
- public Builder organization(String organization) {
- delegateBuilder.organization(organization);
- return this;
- }
-
- /**
- * Sets the project ID.
- *
- * @param project the project ID
- * @return this builder for method chaining
- */
- public Builder project(String project) {
- delegateBuilder.project(project);
- return this;
- }
-
- /**
- * Sets the webhook secret.
- *
- * @param webhookSecret the webhook secret
- * @return this builder for method chaining
- */
- public Builder webhookSecret(String webhookSecret) {
- delegateBuilder.webhookSecret(webhookSecret);
- return this;
- }
-
- /**
- * Sets the base URL.
- *
- * @param baseUrl the base URL
- * @return this builder for method chaining
- */
- public Builder baseUrl(String baseUrl) {
- delegateBuilder.baseUrl(baseUrl);
- return this;
- }
-
- /**
- * Builds the wrapped OpenAI client.
- *
- * @return a new wrapped OpenAI client
- */
- public WrappedOpenAIClient build() {
- return new WrappedOpenAIClient(delegateBuilder.build());
- }
- }
-
- /**
- * Creates a new builder for constructing wrapped OpenAI clients.
- *
- * @return a new builder instance
- */
- public static Builder builder() {
- return new Builder();
- }
-
- /**
- * Wraps an existing OpenAI client to add LangSmith tracing capabilities.
- *
- * This is a convenience method equivalent to using the constructor directly.
- *
- * @param client the OpenAI client to wrap
- * @return a wrapped client that delegates to the original client
- * @throws IllegalArgumentException if client is null
- */
- public static WrappedOpenAIClient wrap(OpenAIClient client) {
- return new WrappedOpenAIClient(client);
- }
-
- /**
- * Creates a wrapped OpenAI client from environment variables.
- *
- * @return a new wrapped OpenAI client configured from environment variables
- */
- public static WrappedOpenAIClient fromEnv() {
- return builder().fromEnv().build();
- }
-}
diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedResponseService.java b/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedResponseService.java
deleted file mode 100644
index 2a224002..00000000
--- a/langsmith-java-core/src/main/java/com/langchain/smith/wrappers/openai/WrappedResponseService.java
+++ /dev/null
@@ -1,433 +0,0 @@
-package com.langchain.smith.wrappers.openai;
-
-import com.openai.core.ClientOptions;
-import com.openai.core.RequestOptions;
-import com.openai.core.http.StreamResponse;
-import com.openai.models.responses.Response;
-import com.openai.models.responses.ResponseCreateParams;
-import com.openai.models.responses.ResponseStreamEvent;
-import com.openai.models.responses.StructuredResponse;
-import com.openai.models.responses.StructuredResponseCreateParams;
-import com.openai.services.blocking.ResponseService;
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.context.Scope;
-import java.util.function.Consumer;
-
-/**
- * Wrapped ResponseService that adds OpenTelemetry tracing to response operations.
- */
-class WrappedResponseService implements ResponseService {
-
- private final ResponseService delegate;
-
- WrappedResponseService(ResponseService delegate) {
- this.delegate = delegate;
- }
-
- @Override
- public ResponseService.WithRawResponse withRawResponse() {
- return delegate.withRawResponse();
- }
-
- @Override
- public ResponseService withOptions(Consumer options) {
- return new WrappedResponseService(delegate.withOptions(options));
- }
-
- @Override
- public com.openai.services.blocking.responses.InputItemService inputItems() {
- return delegate.inputItems();
- }
-
- @Override
- public com.openai.services.blocking.responses.InputTokenService inputTokens() {
- return delegate.inputTokens();
- }
-
- @Override
- public Response create() {
- return create((ResponseCreateParams) null, null);
- }
-
- @Override
- public Response create(RequestOptions requestOptions) {
- return create((ResponseCreateParams) null, requestOptions);
- }
-
- @Override
- public Response create(ResponseCreateParams params) {
- return create(params, null);
- }
-
- @Override
- public Response create(ResponseCreateParams params, RequestOptions requestOptions) {
- // Extract model from params
- String model =
- params != null && params.model().isPresent() ? params.model().toString() : null;
-
- Span span = TracingUtils.createSpanBuilder(model, "response").startSpan();
-
- try (Scope scope = span.makeCurrent()) {
- // Set experiment context attributes if present
- setExperimentContextAttributes(span);
-
- // Set request attributes (core attributes already set on builder)
- TracingUtils.setRequestAttributes(span, model);
-
- // Set request parameters if available (handle Optional types)
- if (params != null) {
- Double temperature = params.temperature().orElse(null);
- Double topP = params.topP().orElse(null);
- TracingUtils.setRequestParameters(span, temperature, topP, null);
- }
-
- Response result;
- // If requestOptions is null, use the single-parameter version
- if (requestOptions == null) {
- result = delegate.create(params);
- } else {
- result = delegate.create(params, requestOptions);
- }
-
- // Extract response model (simplified - just use the request model)
- TracingUtils.setResponseMetadata(span, model, null);
-
- // Extract usage information from result
- if (result.usage().isPresent()) {
- com.openai.models.responses.ResponseUsage usage = result.usage().get();
- TracingUtils.setResponseAttributes(
- span, (long) usage.inputTokens(), (long) usage.outputTokens(), (long) usage.totalTokens());
- }
-
- if (result.status() != null) {
- span.setAttribute("gen_ai.response.status", result.status().toString());
- }
-
- return result;
- } catch (Exception e) {
- TracingUtils.recordException(span, e);
- throw e;
- } finally {
- span.end();
- }
- }
-
- @Override
- public StructuredResponse create(StructuredResponseCreateParams params) {
- return create(params, null);
- }
-
- @Override
- public StructuredResponse create(StructuredResponseCreateParams params, RequestOptions requestOptions) {
- // Get model from rawParams
- String model = params != null
- && params.rawParams() != null
- && params.rawParams().model() != null
- ? params.rawParams().model().toString()
- : null;
-
- Span span = TracingUtils.createSpanBuilder(model, "response").startSpan();
-
- try (Scope scope = span.makeCurrent()) {
- // Set experiment context attributes if present
- setExperimentContextAttributes(span);
-
- // Set request attributes (core attributes already set on builder)
- TracingUtils.setRequestAttributes(span, model);
-
- // Set request parameters if available (handle Optional types)
- if (params != null && params.rawParams() != null) {
- Double temperature = params.rawParams().temperature().orElse(null);
- Double topP = params.rawParams().topP().orElse(null);
- TracingUtils.setRequestParameters(span, temperature, topP, null);
- }
-
- StructuredResponse result;
- // If requestOptions is null, use the single-parameter version
- if (requestOptions == null) {
- result = delegate.create(params);
- } else {
- result = delegate.create(params, requestOptions);
- }
-
- // Extract response model (simplified - just use the request model)
- TracingUtils.setResponseMetadata(span, model, null);
-
- if (result.usage().isPresent()) {
- com.openai.models.responses.ResponseUsage usage = result.usage().get();
- TracingUtils.setResponseAttributes(
- span, (long) usage.inputTokens(), (long) usage.outputTokens(), (long) usage.totalTokens());
- }
-
- return result;
- } catch (Exception e) {
- TracingUtils.recordException(span, e);
- throw e;
- } finally {
- span.end();
- }
- }
-
- @Override
- public StreamResponse createStreaming() {
- return createStreaming((ResponseCreateParams) null, null);
- }
-
- @Override
- public StreamResponse createStreaming(RequestOptions requestOptions) {
- return createStreaming((ResponseCreateParams) null, requestOptions);
- }
-
- @Override
- public StreamResponse createStreaming(ResponseCreateParams params) {
- return createStreaming(params, null);
- }
-
- @Override
- public StreamResponse createStreaming(
- ResponseCreateParams params, RequestOptions requestOptions) {
- String model = params != null && params.model() != null ? params.model().toString() : null;
-
- Span span = TracingUtils.createSpanBuilder(model, "response").startSpan();
-
- try (Scope scope = span.makeCurrent()) {
- // Set experiment context attributes if present
- setExperimentContextAttributes(span);
-
- // Set request attributes (core attributes already set on builder)
- TracingUtils.setRequestAttributes(span, model);
- span.setAttribute("gen_ai.streaming", true);
-
- // Set request parameters if available (handle Optional types)
- if (params != null) {
- Double temperature = params.temperature().orElse(null);
- Double topP = params.topP().orElse(null);
- TracingUtils.setRequestParameters(span, temperature, topP, null);
- }
-
- StreamResponse result;
- // If requestOptions is null, use the single-parameter version
- if (requestOptions == null) {
- result = delegate.createStreaming(params);
- } else {
- result = delegate.createStreaming(params, requestOptions);
- }
-
- return result;
- } catch (Exception e) {
- TracingUtils.recordException(span, e);
- throw e;
- } finally {
- // Note: For streaming, the span will end immediately
- span.end();
- }
- }
-
- @Override
- public StreamResponse createStreaming(StructuredResponseCreateParams> params) {
- return createStreaming(params, null);
- }
-
- @Override
- public StreamResponse createStreaming(
- StructuredResponseCreateParams> params, RequestOptions requestOptions) {
- // Get model from rawParams
- String model = params != null
- && params.rawParams() != null
- && params.rawParams().model() != null
- ? params.rawParams().model().toString()
- : null;
-
- Span span = TracingUtils.createSpanBuilder(model, "response").startSpan();
-
- try (Scope scope = span.makeCurrent()) {
- // Set experiment context attributes if present
- setExperimentContextAttributes(span);
-
- // Set request attributes (core attributes already set on builder)
- TracingUtils.setRequestAttributes(span, model);
- span.setAttribute("gen_ai.streaming", true);
-
- // Set request parameters if available (handle Optional types)
- if (params != null && params.rawParams() != null) {
- Double temperature = params.rawParams().temperature().orElse(null);
- Double topP = params.rawParams().topP().orElse(null);
- TracingUtils.setRequestParameters(span, temperature, topP, null);
- }
-
- StreamResponse result;
- // If requestOptions is null, use the single-parameter version
- if (requestOptions == null) {
- result = delegate.createStreaming(params);
- } else {
- result = delegate.createStreaming(params, requestOptions);
- }
-
- return result;
- } catch (Exception e) {
- TracingUtils.recordException(span, e);
- throw e;
- } finally {
- span.end();
- }
- }
-
- /**
- * Sets experiment context attributes on the span if they are present.
- * This includes reference example ID, session ID, and metadata.
- *
- * @param span the span to set attributes on
- */
- private void setExperimentContextAttributes(Span span) {
- // Set reference example ID if present
- ExperimentContext.current()
- .getReferenceExampleId()
- .filter(id -> !id.isEmpty())
- .ifPresent(id -> span.setAttribute("langsmith.reference_example_id", id));
-
- // Set session ID (experiment ID) if present
- // This is critical for linking runs to experiments in the dataset's Experiments tab
- ExperimentContext.current()
- .getSessionId()
- .filter(id -> !id.isEmpty())
- .ifPresent(id -> span.setAttribute("langsmith.trace.session_id", id));
-
- // Set custom metadata
- java.util.Map metadata = ExperimentContext.current().getMetadata();
- for (java.util.Map.Entry entry : metadata.entrySet()) {
- span.setAttribute("langsmith.metadata." + entry.getKey(), entry.getValue());
- }
- }
-
- // Delegate other methods without tracing (for now)
- @Override
- public Response retrieve(String responseId) {
- return delegate.retrieve(responseId);
- }
-
- @Override
- public Response retrieve(String responseId, RequestOptions requestOptions) {
- return delegate.retrieve(responseId, requestOptions);
- }
-
- @Override
- public Response retrieve(String responseId, com.openai.models.responses.ResponseRetrieveParams params) {
- return delegate.retrieve(responseId, params);
- }
-
- @Override
- public Response retrieve(
- String responseId,
- com.openai.models.responses.ResponseRetrieveParams params,
- RequestOptions requestOptions) {
- return delegate.retrieve(responseId, params, requestOptions);
- }
-
- @Override
- public Response retrieve(com.openai.models.responses.ResponseRetrieveParams params) {
- return delegate.retrieve(params);
- }
-
- @Override
- public Response retrieve(com.openai.models.responses.ResponseRetrieveParams params, RequestOptions requestOptions) {
- return delegate.retrieve(params, requestOptions);
- }
-
- @Override
- public StreamResponse retrieveStreaming(String responseId) {
- return delegate.retrieveStreaming(responseId);
- }
-
- @Override
- public StreamResponse retrieveStreaming(String responseId, RequestOptions requestOptions) {
- return delegate.retrieveStreaming(responseId, requestOptions);
- }
-
- @Override
- public StreamResponse retrieveStreaming(
- String responseId, com.openai.models.responses.ResponseRetrieveParams params) {
- return delegate.retrieveStreaming(responseId, params);
- }
-
- @Override
- public StreamResponse retrieveStreaming(
- String responseId,
- com.openai.models.responses.ResponseRetrieveParams params,
- RequestOptions requestOptions) {
- return delegate.retrieveStreaming(responseId, params, requestOptions);
- }
-
- @Override
- public StreamResponse retrieveStreaming(
- com.openai.models.responses.ResponseRetrieveParams params) {
- return delegate.retrieveStreaming(params);
- }
-
- @Override
- public StreamResponse retrieveStreaming(
- com.openai.models.responses.ResponseRetrieveParams params, RequestOptions requestOptions) {
- return delegate.retrieveStreaming(params, requestOptions);
- }
-
- @Override
- public void delete(String responseId) {
- delegate.delete(responseId);
- }
-
- @Override
- public void delete(String responseId, RequestOptions requestOptions) {
- delegate.delete(responseId, requestOptions);
- }
-
- @Override
- public void delete(String responseId, com.openai.models.responses.ResponseDeleteParams params) {
- delegate.delete(responseId, params);
- }
-
- @Override
- public void delete(
- String responseId, com.openai.models.responses.ResponseDeleteParams params, RequestOptions requestOptions) {
- delegate.delete(responseId, params, requestOptions);
- }
-
- @Override
- public void delete(com.openai.models.responses.ResponseDeleteParams params) {
- delegate.delete(params);
- }
-
- @Override
- public void delete(com.openai.models.responses.ResponseDeleteParams params, RequestOptions requestOptions) {
- delegate.delete(params, requestOptions);
- }
-
- @Override
- public Response cancel(String responseId) {
- return delegate.cancel(responseId);
- }
-
- @Override
- public Response cancel(String responseId, RequestOptions requestOptions) {
- return delegate.cancel(responseId, requestOptions);
- }
-
- @Override
- public Response cancel(String responseId, com.openai.models.responses.ResponseCancelParams params) {
- return delegate.cancel(responseId, params);
- }
-
- @Override
- public Response cancel(
- String responseId, com.openai.models.responses.ResponseCancelParams params, RequestOptions requestOptions) {
- return delegate.cancel(responseId, params, requestOptions);
- }
-
- @Override
- public Response cancel(com.openai.models.responses.ResponseCancelParams params) {
- return delegate.cancel(params);
- }
-
- @Override
- public Response cancel(com.openai.models.responses.ResponseCancelParams params, RequestOptions requestOptions) {
- return delegate.cancel(params, requestOptions);
- }
-}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelConfig.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelConfig.kt
new file mode 100644
index 00000000..64fd8947
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelConfig.kt
@@ -0,0 +1,95 @@
+package com.langchain.smith.otel
+
+import java.time.Duration
+
+/**
+ * Configuration for OpenTelemetry trace export.
+ *
+ * This class provides configuration options for exporting LangSmith runs as OpenTelemetry traces.
+ * Configuration can be loaded from environment variables or system properties, or set manually via
+ * the builder.
+ *
+ * Environment variables:
+ * - OTEL_EXPORTER_OTLP_ENDPOINT: The OTEL endpoint URL (default: http://localhost:4318/v1/traces)
+ * - OTEL_EXPORTER_OTLP_ENABLED: Whether export is enabled (default: false)
+ * - OTEL_EXPORTER_OTLP_TIMEOUT: Timeout in seconds (default: 10)
+ *
+ * System properties:
+ * - langchain.otel.endpoint
+ * - langchain.otel.enabled
+ * - langchain.otel.timeout
+ */
+class OtelConfig
+private constructor(
+ val endpoint: String,
+ val enabled: Boolean,
+ val timeout: Duration,
+ val headers: Map,
+ val serviceName: String?,
+) {
+ class Builder {
+ private var endpoint: String = "http://localhost:4318/v1/traces"
+ private var enabled: Boolean = false
+ private var timeout: Duration = Duration.ofSeconds(10)
+ private val headers: MutableMap = mutableMapOf()
+ private var serviceName: String? = null
+
+ fun endpoint(endpoint: String) = apply { this.endpoint = endpoint }
+
+ fun enabled(enabled: Boolean) = apply { this.enabled = enabled }
+
+ fun timeout(timeout: Duration) = apply { this.timeout = timeout }
+
+ fun headers(headers: Map) = apply {
+ this.headers.clear()
+ this.headers.putAll(headers)
+ }
+
+ fun putHeader(name: String, value: String) = apply { headers[name] = value }
+
+ fun serviceName(serviceName: String?) = apply { this.serviceName = serviceName }
+
+ fun build(): OtelConfig =
+ OtelConfig(
+ endpoint = endpoint,
+ enabled = enabled,
+ timeout = timeout,
+ headers = headers.toMap(),
+ serviceName = serviceName,
+ )
+ }
+
+ companion object {
+ @JvmStatic fun builder(): Builder = Builder()
+
+ @JvmStatic
+ fun fromEnv(): OtelConfig {
+ val endpoint =
+ System.getProperty("langchain.otel.endpoint")
+ ?: System.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
+ ?: "http://localhost:4318/v1/traces"
+
+ val enabledStr =
+ System.getProperty("langchain.otel.enabled")
+ ?: System.getenv("OTEL_EXPORTER_OTLP_ENABLED")
+ ?: "false"
+ val enabled = "true".equals(enabledStr, ignoreCase = true)
+
+ val timeoutStr =
+ System.getProperty("langchain.otel.timeout")
+ ?: System.getenv("OTEL_EXPORTER_OTLP_TIMEOUT")
+ ?: "10"
+ val timeoutSeconds = timeoutStr.toLongOrNull() ?: 10L
+ val timeout = Duration.ofSeconds(timeoutSeconds)
+
+ val serviceName =
+ System.getProperty("langchain.otel.service.name")
+ ?: System.getenv("OTEL_SERVICE_NAME")
+
+ val configBuilder = builder().endpoint(endpoint).enabled(enabled).timeout(timeout)
+ return (if (serviceName != null) configBuilder.serviceName(serviceName)
+ else configBuilder)
+ .build()
+ }
+ }
+}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelSpanCreator.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelSpanCreator.kt
new file mode 100644
index 00000000..ca2e43b5
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelSpanCreator.kt
@@ -0,0 +1,103 @@
+package com.langchain.smith.otel
+
+import io.opentelemetry.api.common.AttributeKey
+import io.opentelemetry.api.trace.Span
+import io.opentelemetry.api.trace.Tracer
+import io.opentelemetry.context.Context
+
+/**
+ * Utility object for creating OpenTelemetry spans with Gen AI semantic conventions. Provides helper
+ * methods to create spans for LLM, tool, retrieval, and chain operations.
+ */
+object OtelSpanCreator {
+
+ @JvmStatic
+ fun createLlmSpan(
+ tracer: Tracer,
+ name: String,
+ system: String,
+ model: String,
+ serviceName: String?,
+ sessionId: String?,
+ ): Span {
+ val span = tracer.spanBuilder(name).setParent(Context.current()).startSpan()
+ span.setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "chat")
+ span.setAttribute(AttributeKey.stringKey("gen_ai.system"), system)
+ span.setAttribute(AttributeKey.stringKey("gen_ai.request.model"), model)
+ serviceName?.let { span.setAttribute(AttributeKey.stringKey("service.name"), it) }
+ sessionId?.let { span.setAttribute(AttributeKey.stringKey("session.id"), it) }
+ return span
+ }
+
+ @JvmStatic
+ fun setInput(span: Span, input: String?) {
+ input?.let { span.setAttribute(AttributeKey.stringKey("gen_ai.prompt"), it) }
+ }
+
+ @JvmStatic
+ fun setOutput(span: Span, output: String?) {
+ output?.let { span.setAttribute(AttributeKey.stringKey("gen_ai.completion"), it) }
+ }
+
+ @JvmStatic
+ fun setOutputMessages(span: Span, messagesJson: String?) {
+ messagesJson?.let {
+ span.setAttribute(AttributeKey.stringKey("gen_ai.output.messages"), it)
+ }
+ }
+
+ @JvmStatic
+ fun setInputMessages(span: Span, messagesJson: String?) {
+ messagesJson?.let { span.setAttribute(AttributeKey.stringKey("gen_ai.input.messages"), it) }
+ }
+
+ @JvmStatic
+ fun setTokenUsage(span: Span, inputTokens: Int, outputTokens: Int) {
+ span.setAttribute(AttributeKey.longKey("gen_ai.usage.input_tokens"), inputTokens.toLong())
+ span.setAttribute(AttributeKey.longKey("gen_ai.usage.output_tokens"), outputTokens.toLong())
+ }
+
+ @JvmStatic
+ fun createToolSpan(
+ tracer: Tracer,
+ name: String,
+ toolName: String,
+ serviceName: String?,
+ sessionId: String?,
+ ): Span {
+ val span = tracer.spanBuilder(name).setParent(Context.current()).startSpan()
+ span.setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "tool")
+ span.setAttribute(AttributeKey.stringKey("tool.name"), toolName)
+ serviceName?.let { span.setAttribute(AttributeKey.stringKey("service.name"), it) }
+ sessionId?.let { span.setAttribute(AttributeKey.stringKey("session.id"), it) }
+ return span
+ }
+
+ @JvmStatic
+ fun createRetrievalSpan(
+ tracer: Tracer,
+ name: String,
+ serviceName: String?,
+ sessionId: String?,
+ ): Span {
+ val span = tracer.spanBuilder(name).setParent(Context.current()).startSpan()
+ span.setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "retrieval")
+ serviceName?.let { span.setAttribute(AttributeKey.stringKey("service.name"), it) }
+ sessionId?.let { span.setAttribute(AttributeKey.stringKey("session.id"), it) }
+ return span
+ }
+
+ @JvmStatic
+ fun createChainSpan(
+ tracer: Tracer,
+ name: String,
+ serviceName: String?,
+ sessionId: String?,
+ ): Span {
+ val span = tracer.spanBuilder(name).setParent(Context.current()).startSpan()
+ span.setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "chat")
+ serviceName?.let { span.setAttribute(AttributeKey.stringKey("service.name"), it) }
+ sessionId?.let { span.setAttribute(AttributeKey.stringKey("session.id"), it) }
+ return span
+ }
+}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelTraceExporter.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelTraceExporter.kt
new file mode 100644
index 00000000..e3118b23
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/otel/OtelTraceExporter.kt
@@ -0,0 +1,139 @@
+package com.langchain.smith.otel
+
+import io.opentelemetry.api.OpenTelemetry
+import io.opentelemetry.api.trace.Tracer
+import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
+import io.opentelemetry.sdk.OpenTelemetrySdk
+import io.opentelemetry.sdk.common.CompletableResultCode
+import io.opentelemetry.sdk.resources.Resource
+import io.opentelemetry.sdk.trace.SdkTracerProvider
+import io.opentelemetry.sdk.trace.export.BatchSpanProcessor
+import io.opentelemetry.semconv.ResourceAttributes
+import java.time.Duration
+import java.util.concurrent.TimeUnit
+import org.slf4j.LoggerFactory
+
+/**
+ * Manages OpenTelemetry SDK for exporting traces to OTLP endpoints.
+ *
+ * This class initializes the OpenTelemetry SDK with OTLP HTTP export capabilities, providing a
+ * Tracer for creating spans with Gen AI semantic conventions.
+ */
+class OtelTraceExporter
+private constructor(
+ private val config: OtelConfig,
+ private val openTelemetry: OpenTelemetry,
+ val tracer: Tracer,
+ private val tracerProvider: SdkTracerProvider,
+ projectName: String?,
+) {
+ val projectName: String = projectName ?: "default"
+
+ fun shutdown(): CompletableResultCode = tracerProvider.shutdown()
+
+ fun flush(): CompletableResultCode = tracerProvider.forceFlush()
+
+ companion object {
+ private val logger = LoggerFactory.getLogger(OtelTraceExporter::class.java)
+ private const val INSTRUMENTATION_NAME = "langsmith-java"
+ private const val INSTRUMENTATION_VERSION = "0.1.0"
+
+ @JvmStatic fun fromEnv(): OtelTraceExporter = fromConfig(OtelConfig.fromEnv())
+
+ @JvmStatic
+ fun fromConfig(config: OtelConfig): OtelTraceExporter {
+ val serviceName = config.serviceName ?: "langsmith-app"
+ val resource =
+ Resource.getDefault()
+ .toBuilder()
+ .put(ResourceAttributes.SERVICE_NAME, serviceName)
+ .put(ResourceAttributes.SERVICE_VERSION, INSTRUMENTATION_VERSION)
+ .build()
+
+ if (!config.enabled) {
+ val tracerProvider = SdkTracerProvider.builder().build()
+ val openTelemetry =
+ OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build()
+ val tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION)
+ return OtelTraceExporter(config, openTelemetry, tracer, tracerProvider, null)
+ }
+
+ val exporterBuilder =
+ OtlpHttpSpanExporter.builder()
+ .setEndpoint(config.endpoint)
+ .setTimeout(config.timeout)
+ for ((key, value) in config.headers) {
+ exporterBuilder.addHeader(key, value)
+ }
+ val exporter = exporterBuilder.build()
+
+ val spanProcessor =
+ BatchSpanProcessor.builder(exporter)
+ .setScheduleDelay(5, TimeUnit.SECONDS)
+ .setMaxQueueSize(2048)
+ .setMaxExportBatchSize(512)
+ .build()
+
+ val tracerProvider =
+ SdkTracerProvider.builder()
+ .addResource(resource)
+ .addSpanProcessor(spanProcessor)
+ .build()
+
+ val openTelemetry = OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build()
+ val tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION)
+ val projectName = config.headers["Langsmith-Project"] ?: "default"
+
+ logger.debug(
+ "Created OpenTelemetry SDK with endpoint: {}, timeout: {}",
+ config.endpoint,
+ config.timeout,
+ )
+ logger.debug("Headers: {}", config.headers)
+ logger.debug("Service name: {}, Project name: {}", serviceName, projectName)
+
+ return OtelTraceExporter(config, openTelemetry, tracer, tracerProvider, projectName)
+ }
+
+ @JvmStatic fun builder(): Builder = Builder()
+ }
+
+ class Builder {
+ private var config: OtelConfig? = null
+ private var endpoint: String? = null
+ private var enabled: Boolean? = null
+ private var timeout: Duration? = null
+ private val headers: MutableMap = mutableMapOf()
+ private var serviceName: String? = null
+
+ fun config(config: OtelConfig) = apply { this.config = config }
+
+ fun endpoint(endpoint: String) = apply { this.endpoint = endpoint }
+
+ fun enabled(enabled: Boolean) = apply { this.enabled = enabled }
+
+ fun timeout(timeout: Duration) = apply { this.timeout = timeout }
+
+ fun headers(headers: Map) = apply {
+ this.headers.clear()
+ this.headers.putAll(headers)
+ }
+
+ fun putHeader(name: String, value: String) = apply { headers[name] = value }
+
+ fun serviceName(serviceName: String?) = apply { this.serviceName = serviceName }
+
+ fun build(): OtelTraceExporter {
+ val finalConfig =
+ config
+ ?: OtelConfig.builder()
+ .endpoint(endpoint ?: "http://localhost:4318/v1/traces")
+ .enabled(enabled ?: false)
+ .timeout(timeout ?: Duration.ofSeconds(10))
+ .headers(headers)
+ .let { b -> if (serviceName != null) b.serviceName(serviceName) else b }
+ .build()
+ return fromConfig(finalConfig)
+ }
+ }
+}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/ExperimentContext.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/ExperimentContext.kt
new file mode 100644
index 00000000..9d613c22
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/ExperimentContext.kt
@@ -0,0 +1,91 @@
+package com.langchain.smith.wrappers.openai
+
+import io.opentelemetry.context.Context
+import io.opentelemetry.context.ContextKey
+import io.opentelemetry.context.Scope
+import java.util.Optional
+
+/**
+ * Immutable context for experiment metadata that will be automatically attached to OpenTelemetry
+ * spans.
+ *
+ * When you set experiment context using this class, the wrapped OpenAI client will automatically
+ * attach it to the spans it creates. Follows the OpenTelemetry Context pattern with immutable
+ * context objects and explicit scope management.
+ *
+ * Example (recommended - use scope):
+ * ```
+ * ExperimentContext.withExperiment("example-123", "session-789").use { scope ->
+ * val completion = client.chat().completions().create(params)
+ * }
+ * ```
+ *
+ * Thread Safety: This class is thread-safe. Each thread maintains its own context via
+ * OpenTelemetry's Context mechanism.
+ */
+class ExperimentContext private constructor(private val data: ExperimentData) {
+
+ fun withReferenceExampleId(exampleId: String): ExperimentContext {
+ require(exampleId.isNotBlank()) { "exampleId cannot be null or empty" }
+ return ExperimentContext(data.withReferenceExampleId(exampleId))
+ }
+
+ fun withSessionId(sessionId: String): ExperimentContext {
+ require(sessionId.isNotBlank()) { "sessionId cannot be null or empty" }
+ return ExperimentContext(data.withSessionId(sessionId))
+ }
+
+ fun withMetadata(key: String, value: String): ExperimentContext {
+ require(key.isNotBlank()) { "metadata key cannot be null or empty" }
+ require(value.isNotBlank()) { "metadata value cannot be null or empty" }
+ return ExperimentContext(data.withMetadata(key, value))
+ }
+
+ private fun makeCurrent(): Scope {
+ val otelContext = Context.current().with(CONTEXT_KEY, data)
+ return otelContext.makeCurrent()
+ }
+
+ fun getReferenceExampleId(): Optional = Optional.ofNullable(data.referenceExampleId)
+
+ fun getSessionId(): Optional = Optional.ofNullable(data.sessionId)
+
+ fun getMetadata(): Map = data.metadata
+
+ companion object {
+ private val CONTEXT_KEY = ContextKey.named("langsmith-experiment-context")
+
+ @JvmStatic
+ fun current(): ExperimentContext {
+ val otelContext = Context.current()
+ val data = otelContext.get(CONTEXT_KEY) ?: ExperimentData.empty()
+ return ExperimentContext(data)
+ }
+
+ @JvmStatic
+ fun withExperiment(exampleId: String, sessionId: String): Scope {
+ require(exampleId.isNotBlank()) { "exampleId cannot be null or empty" }
+ require(sessionId.isNotBlank()) { "sessionId cannot be null or empty" }
+ return current()
+ .withReferenceExampleId(exampleId)
+ .withSessionId(sessionId)
+ .makeCurrent()
+ }
+ }
+
+ private data class ExperimentData(
+ val referenceExampleId: String?,
+ val sessionId: String?,
+ val metadata: Map,
+ ) {
+ fun withReferenceExampleId(exampleId: String) = copy(referenceExampleId = exampleId)
+
+ fun withSessionId(sessionId: String) = copy(sessionId = sessionId)
+
+ fun withMetadata(key: String, value: String) = copy(metadata = metadata + (key to value))
+
+ companion object {
+ fun empty() = ExperimentData(null, null, emptyMap())
+ }
+ }
+}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.kt
new file mode 100644
index 00000000..745e8c77
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.kt
@@ -0,0 +1,246 @@
+package com.langchain.smith.wrappers.openai
+
+import io.opentelemetry.api.GlobalOpenTelemetry
+import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
+import io.opentelemetry.sdk.OpenTelemetrySdk
+import io.opentelemetry.sdk.common.CompletableResultCode
+import io.opentelemetry.sdk.resources.Resource
+import io.opentelemetry.sdk.trace.SdkTracerProvider
+import io.opentelemetry.sdk.trace.data.SpanData
+import io.opentelemetry.sdk.trace.export.BatchSpanProcessor
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor
+import io.opentelemetry.sdk.trace.export.SpanExporter
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.util.concurrent.TimeUnit
+import org.slf4j.LoggerFactory
+
+/**
+ * Configuration utility for setting up OpenTelemetry to export traces to LangSmith.
+ *
+ * Example usage:
+ * ```
+ * OpenTelemetryConfig.builder()
+ * .apiKey("your-langsmith-api-key")
+ * .projectName("your-project-name")
+ * .build()
+ *
+ * // Or using only environment variables (LANGSMITH_API_KEY, LANGSMITH_PROJECT)
+ * OpenTelemetryConfig.builder().build()
+ *
+ * val client = WrappedOpenAIClient.fromEnv()
+ * ```
+ */
+object OpenTelemetryConfig {
+ private val logger = LoggerFactory.getLogger(OpenTelemetryConfig::class.java)
+
+ const val DEFAULT_BASE_URL: String = "https://api.smith.langchain.com"
+ const val OTLP_TRACES_PATH: String = "/otel/v1/traces"
+
+ enum class SpanProcessorType {
+ /** Batch export - best for production. */
+ BATCH,
+ /** Simple synchronous export on span.end() - best for testing. */
+ SIMPLE,
+ }
+
+ @JvmStatic fun builder(): Builder = Builder()
+
+ @JvmStatic fun flush(): Boolean = flush(5, TimeUnit.SECONDS)
+
+ @JvmStatic
+ fun flush(timeout: Long, unit: TimeUnit): Boolean {
+ val openTelemetry = GlobalOpenTelemetry.get()
+ if (openTelemetry is OpenTelemetrySdk) {
+ val tracerProvider = openTelemetry.getSdkTracerProvider()
+ if (tracerProvider != null) {
+ return try {
+ val result = tracerProvider.forceFlush()
+ result.join(timeout, unit)
+ if (!result.isSuccess) logger.warn("Flush did not complete successfully")
+ result.isSuccess
+ } catch (e: Exception) {
+ logger.warn("Failed to flush spans", e)
+ false
+ }
+ }
+ }
+ return true
+ }
+
+ @JvmStatic
+ fun shutdown() {
+ val openTelemetry = GlobalOpenTelemetry.get()
+ if (openTelemetry is OpenTelemetrySdk) {
+ val tracerProvider = openTelemetry.getSdkTracerProvider()
+ if (tracerProvider != null) {
+ try {
+ tracerProvider.shutdown().join(5, TimeUnit.SECONDS)
+ } catch (e: Exception) {
+ logger.warn("Failed to shutdown OpenTelemetry", e)
+ }
+ }
+ }
+ }
+
+ private fun buildOtlpEndpoint(baseUrl: String?): String {
+ var effectiveBaseUrl = baseUrl
+ if (effectiveBaseUrl.isNullOrEmpty()) effectiveBaseUrl = System.getenv("LANGSMITH_ENDPOINT")
+ if (effectiveBaseUrl.isNullOrEmpty()) effectiveBaseUrl = DEFAULT_BASE_URL
+ if (effectiveBaseUrl!!.endsWith("/")) effectiveBaseUrl = effectiveBaseUrl.dropLast(1)
+ return effectiveBaseUrl + OTLP_TRACES_PATH
+ }
+
+ class Builder {
+ private var apiKey: String? = System.getenv("LANGSMITH_API_KEY")
+ private var projectName: String? = System.getenv("LANGSMITH_PROJECT")
+ private var serviceName: String? = System.getenv("OTEL_SERVICE_NAME")
+ private var baseUrl: String? = System.getenv("LANGSMITH_ENDPOINT")
+ private var processorType: SpanProcessorType = SpanProcessorType.BATCH
+ private var maxBatchSize: Int = 512
+
+ fun apiKey(apiKey: String) = apply { this.apiKey = apiKey }
+
+ fun projectName(projectName: String?) = apply { this.projectName = projectName }
+
+ fun serviceName(serviceName: String?) = apply { this.serviceName = serviceName }
+
+ fun baseUrl(baseUrl: String?) = apply { this.baseUrl = baseUrl }
+
+ fun processorType(processorType: SpanProcessorType) = apply {
+ this.processorType = processorType
+ }
+
+ fun maxBatchSize(maxBatchSize: Int) = apply { this.maxBatchSize = maxBatchSize }
+
+ fun build(): io.opentelemetry.api.OpenTelemetry {
+ require(!apiKey.isNullOrEmpty()) {
+ "LangSmith API key is required. Set it using apiKey() or LANGSMITH_API_KEY environment variable."
+ }
+ val endpointUrl = buildOtlpEndpoint(baseUrl)
+ val exporterBuilder =
+ OtlpHttpSpanExporter.builder()
+ .setEndpoint(endpointUrl)
+ .addHeader("x-api-key", apiKey!!)
+ if (!projectName.isNullOrEmpty()) {
+ exporterBuilder.addHeader("Langsmith-Project", projectName!!)
+ }
+ val spanExporter = exporterBuilder.build()
+ val loggingExporter = LoggingSpanExporter(spanExporter)
+ val resource =
+ Resource.getDefault()
+ .merge(
+ Resource.builder()
+ .put("service.name", serviceName ?: "langsmith-java-otel-wrappers")
+ .build()
+ )
+ val spanProcessor =
+ when (processorType) {
+ SpanProcessorType.SIMPLE -> SimpleSpanProcessor.create(loggingExporter)
+ SpanProcessorType.BATCH ->
+ BatchSpanProcessor.builder(loggingExporter)
+ .setScheduleDelay(100, TimeUnit.MILLISECONDS)
+ .setMaxExportBatchSize(maxBatchSize)
+ .setExporterTimeout(5, TimeUnit.SECONDS)
+ .build()
+ }
+ val tracerProvider =
+ SdkTracerProvider.builder()
+ .addSpanProcessor(spanProcessor)
+ .setResource(resource)
+ .build()
+ return OpenTelemetrySdk.builder()
+ .setTracerProvider(tracerProvider)
+ .buildAndRegisterGlobal()
+ }
+
+ private fun buildOtlpEndpoint(baseUrl: String?): String =
+ OpenTelemetryConfig.buildOtlpEndpoint(baseUrl)
+ }
+
+ private class LoggingSpanExporter(private val delegate: SpanExporter) : SpanExporter {
+ override fun export(spans: Collection): CompletableResultCode {
+ if (DEBUG) {
+ logger.debug("[LangSmith] Exporting ${spans.size} span(s):")
+ for (span in spans) {
+ logger.debug(
+ " - ${span.name} (kind=${span.kind}, attributes=${span.attributes.size()})"
+ )
+ }
+ }
+ val result = delegate.export(spans)
+ if (DEBUG) {
+ try {
+ result.join(5, TimeUnit.SECONDS)
+ if (!result.isSuccess) {
+ logger.error(
+ "[LangSmith ERROR] Failed to export ${spans.size} span(s) to LangSmith"
+ )
+ logExportException(result)
+ for (span in spans) {
+ logger.error(
+ " - ${span.name} (traceId=${span.traceId}, spanId=${span.spanId})"
+ )
+ }
+ logger.error(
+ " This usually indicates a network error, authentication problem, or invalid span data"
+ )
+ logger.error(" Check your LANGSMITH_API_KEY and network connectivity")
+ } else {
+ logger.debug("[LangSmith] Successfully exported ${spans.size} span(s)")
+ }
+ } catch (e: Exception) {
+ logger.error("[LangSmith ERROR] Exception waiting for export result", e)
+ }
+ } else {
+ result.whenComplete {
+ if (!result.isSuccess) {
+ logger.error(
+ "[LangSmith ERROR] Failed to export ${spans.size} span(s) to LangSmith"
+ )
+ logExportException(result)
+ logger.error(" Set LANGSMITH_DEBUG=true for more details")
+ }
+ }
+ }
+ return result
+ }
+
+ override fun flush(): CompletableResultCode {
+ if (DEBUG) logger.debug("[LangSmith] Flushing spans...")
+ val result = delegate.flush()
+ result.whenComplete {
+ if (!result.isSuccess) logger.error("[LangSmith ERROR] Failed to flush spans")
+ else if (DEBUG) logger.debug("[LangSmith] Flush completed successfully")
+ }
+ return result
+ }
+
+ override fun shutdown(): CompletableResultCode {
+ if (DEBUG) logger.debug("[LangSmith] Shutting down span exporter...")
+ return delegate.shutdown()
+ }
+
+ companion object {
+ private val DEBUG =
+ java.lang.Boolean.getBoolean("langsmith.debug") ||
+ "true".equals(System.getenv("LANGSMITH_DEBUG"), ignoreCase = true)
+
+ private fun logExportException(result: CompletableResultCode) {
+ try {
+ val getExceptionMethod = result.javaClass.getMethod("getException")
+ val exception = getExceptionMethod.invoke(result) as? Throwable
+ if (exception != null) {
+ logger.error(" Error: ${exception.message}")
+ exception.cause?.let { logger.error(" Caused by: ${it.message}") }
+ if (DEBUG) {
+ val sw = StringWriter()
+ exception.printStackTrace(PrintWriter(sw))
+ logger.debug(" Stack trace:\n${sw}")
+ }
+ }
+ } catch (_: Exception) {}
+ }
+ }
+ }
+}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/TracingUtils.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/TracingUtils.kt
new file mode 100644
index 00000000..4b41bd70
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/TracingUtils.kt
@@ -0,0 +1,97 @@
+package com.langchain.smith.wrappers.openai
+
+import io.opentelemetry.api.trace.Span
+import io.opentelemetry.api.trace.SpanBuilder
+import io.opentelemetry.api.trace.SpanKind
+import io.opentelemetry.api.trace.Tracer
+
+/** Internal utility for OpenTelemetry span creation and management. */
+internal object TracingUtils {
+ private const val INSTRUMENTATION_NAME = "langsmith-java-otel-wrappers"
+
+ fun getTracer(): Tracer {
+ return try {
+ val tracer =
+ io.opentelemetry.api.GlobalOpenTelemetry.get().getTracer(INSTRUMENTATION_NAME)
+ val debug =
+ java.lang.Boolean.getBoolean("langsmith.debug") ||
+ "true".equals(System.getenv("LANGSMITH_DEBUG"), ignoreCase = true)
+ if (debug) {
+ val otel = io.opentelemetry.api.GlobalOpenTelemetry.get()
+ val isNoop = otel.javaClass.name.contains("Noop")
+ println(
+ "[TracingUtils] Tracer obtained: ${tracer.javaClass.name}, OpenTelemetry isNoop: $isNoop"
+ )
+ }
+ tracer
+ } catch (e: Exception) {
+ io.opentelemetry.api.GlobalOpenTelemetry.get().getTracer(INSTRUMENTATION_NAME)
+ }
+ }
+
+ fun createSpanBuilder(
+ model: String?,
+ operationType: String,
+ spanKind: String? = "llm",
+ ): SpanBuilder {
+ val tracer = getTracer()
+ val spanName = "$operationType ${model ?: "unknown"}"
+ val builder =
+ tracer
+ .spanBuilder(spanName)
+ .setSpanKind(SpanKind.CLIENT)
+ .setAttribute("gen_ai.system", "openai")
+ .setAttribute("gen_ai.operation.name", operationType)
+ .setAttribute("gen_ai.provider.name", "openai")
+ spanKind?.let { builder.setAttribute("langsmith.span.kind", it) }
+ return builder
+ }
+
+ fun setRequestAttributes(span: Span, model: String?) {
+ model?.let { span.setAttribute("gen_ai.request.model", it) }
+ }
+
+ fun setRequestParameters(span: Span, temperature: Double?, topP: Double?, maxTokens: Long?) {
+ temperature?.let { span.setAttribute("gen_ai.request.temperature", it) }
+ topP?.let { span.setAttribute("gen_ai.request.top_p", it) }
+ maxTokens?.let { span.setAttribute("gen_ai.request.max_tokens", it) }
+ }
+
+ fun setInputMessages(span: Span, messagesJson: String?) {
+ messagesJson?.let { span.setAttribute("gen_ai.input.messages", it) }
+ }
+
+ fun setOutputMessages(span: Span, messagesJson: String?) {
+ messagesJson?.let { span.setAttribute("gen_ai.output.messages", it) }
+ }
+
+ fun setResponseAttributes(
+ span: Span,
+ inputTokens: Long?,
+ outputTokens: Long?,
+ totalTokens: Long?,
+ ) {
+ inputTokens?.let { span.setAttribute("gen_ai.usage.input_tokens", it) }
+ outputTokens?.let { span.setAttribute("gen_ai.usage.output_tokens", it) }
+ totalTokens?.let { span.setAttribute("gen_ai.usage.total_tokens", it) }
+ }
+
+ fun setResponseMetadata(span: Span, responseModel: String?, finishReason: String?) {
+ responseModel?.let { span.setAttribute("gen_ai.response.model", it) }
+ finishReason?.let { span.setAttribute("gen_ai.response.finish_reason", it) }
+ }
+
+ fun recordException(span: Span, exception: Throwable) {
+ span.recordException(exception)
+ span.setAttribute("error", true)
+ }
+
+ fun escapeJsonString(str: String?): String {
+ if (str == null) return ""
+ return str.replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ }
+}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedChatService.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedChatService.kt
new file mode 100644
index 00000000..439ea6c7
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedChatService.kt
@@ -0,0 +1,526 @@
+package com.langchain.smith.wrappers.openai
+
+import com.openai.core.ClientOptions
+import com.openai.core.RequestOptions
+import com.openai.core.http.StreamResponse
+import com.openai.models.chat.completions.ChatCompletion
+import com.openai.models.chat.completions.ChatCompletionChunk
+import com.openai.models.chat.completions.ChatCompletionCreateParams
+import com.openai.models.chat.completions.StructuredChatCompletion
+import com.openai.models.chat.completions.StructuredChatCompletionCreateParams
+import com.openai.services.blocking.ChatService
+import com.openai.services.blocking.chat.ChatCompletionService
+import io.opentelemetry.api.common.AttributeKey
+import io.opentelemetry.api.trace.Span
+import java.util.function.Consumer
+import java.util.regex.Pattern
+import org.slf4j.LoggerFactory
+
+/** Wrapped ChatService that adds OpenTelemetry tracing to chat completion operations. */
+internal class WrappedChatService(private val delegate: ChatService) : ChatService {
+
+ override fun withRawResponse() = delegate.withRawResponse()
+
+ override fun withOptions(options: Consumer) =
+ WrappedChatService(delegate.withOptions(options))
+
+ override fun completions(): ChatCompletionService =
+ WrappedChatCompletionService(delegate.completions())
+
+ private class WrappedChatCompletionService(private val delegate: ChatCompletionService) :
+ ChatCompletionService {
+ companion object {
+ private val logger = LoggerFactory.getLogger(WrappedChatCompletionService::class.java)
+ }
+
+ override fun withRawResponse() = delegate.withRawResponse()
+
+ override fun withOptions(options: Consumer) =
+ WrappedChatCompletionService(delegate.withOptions(options))
+
+ override fun messages() = delegate.messages()
+
+ override fun create(params: ChatCompletionCreateParams): ChatCompletion =
+ createChat(params, null)
+
+ override fun create(
+ params: ChatCompletionCreateParams,
+ requestOptions: RequestOptions,
+ ): ChatCompletion = createChat(params, requestOptions)
+
+ private fun createChat(
+ params: ChatCompletionCreateParams,
+ requestOptions: RequestOptions?,
+ ): ChatCompletion {
+ val model = params.model()?.toString()
+ val span = TracingUtils.createSpanBuilder(model, "chat").startSpan()
+ if (logger.isDebugEnabled) {
+ logger.debug(
+ "[WrappedChatService] Created span: {}, isRecording: {}, traceId: {}",
+ span.spanContext.spanId,
+ span.isRecording,
+ span.spanContext.traceId,
+ )
+ }
+ try {
+ span.makeCurrent().use {
+ setExperimentContextAttributes(span)
+ TracingUtils.setRequestAttributes(span, model)
+ TracingUtils.setRequestParameters(
+ span,
+ params.temperature().orElse(null),
+ params.topP().orElse(null),
+ params.maxCompletionTokens().orElse(null),
+ )
+ formatInputMessages(params).let { TracingUtils.setInputMessages(span, it) }
+ extractPromptFromParams(params)
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { span.setAttribute(AttributeKey.stringKey("gen_ai.prompt"), it) }
+ val result =
+ if (requestOptions == null) delegate.create(params)
+ else delegate.create(params, requestOptions)
+ val responseModel = result.model()
+ val finishReason =
+ result.choices().firstOrNull()?.finishReason()?.toString() ?: "stop"
+ TracingUtils.setResponseMetadata(span, responseModel, finishReason)
+ formatOutputMessages(result).let { TracingUtils.setOutputMessages(span, it) }
+ extractCompletionFromResult(result)
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { span.setAttribute(AttributeKey.stringKey("gen_ai.completion"), it) }
+ result.usage().ifPresent { usage ->
+ TracingUtils.setResponseAttributes(
+ span,
+ usage.promptTokens().toLong(),
+ usage.completionTokens().toLong(),
+ usage.totalTokens().toLong(),
+ )
+ }
+ return result
+ }
+ } catch (e: Exception) {
+ TracingUtils.recordException(span, e)
+ throw e
+ } finally {
+ if (logger.isDebugEnabled)
+ logger.debug("[WrappedChatService] Ending span: {}", span.spanContext.spanId)
+ span.end()
+ }
+ }
+
+ private fun setExperimentContextAttributes(span: Span) {
+ ExperimentContext.current()
+ .getReferenceExampleId()
+ .filter { it.isNotEmpty() }
+ .ifPresent {
+ span.setAttribute(AttributeKey.stringKey("langsmith.reference_example_id"), it)
+ }
+ ExperimentContext.current()
+ .getSessionId()
+ .filter { it.isNotEmpty() }
+ .ifPresent {
+ span.setAttribute(AttributeKey.stringKey("langsmith.trace.session_id"), it)
+ }
+ for ((key, value) in ExperimentContext.current().getMetadata()) {
+ span.setAttribute(AttributeKey.stringKey("langsmith.metadata.$key"), value)
+ }
+ }
+
+ private fun formatInputMessages(params: ChatCompletionCreateParams): String {
+ if (params.messages().isEmpty()) return "[]"
+ if (logger.isDebugEnabled)
+ logger.debug(
+ "[formatInputMessages] Processing {} message(s)",
+ params.messages().size,
+ )
+ val json = StringBuilder("[")
+ var first = true
+ for (messageParam in params.messages()) {
+ if (!first) json.append(",")
+ first = false
+ json.append("{")
+ var role: String? = null
+ var content: String? = null
+ try {
+ val isUser =
+ messageParam.javaClass.getMethod("isUser").invoke(messageParam) as Boolean
+ val isSystem =
+ messageParam.javaClass.getMethod("isSystem").invoke(messageParam) as Boolean
+ val isAssistant =
+ messageParam.javaClass.getMethod("isAssistant").invoke(messageParam)
+ as Boolean
+ val isTool =
+ messageParam.javaClass.getMethod("isTool").invoke(messageParam) as Boolean
+ val actualMessage =
+ when {
+ isUser -> {
+ role = "user"
+ messageParam.javaClass.getMethod("asUser").invoke(messageParam)
+ }
+ isSystem -> {
+ role = "system"
+ messageParam.javaClass.getMethod("asSystem").invoke(messageParam)
+ }
+ isAssistant -> {
+ role = "assistant"
+ messageParam.javaClass.getMethod("asAssistant").invoke(messageParam)
+ }
+ isTool -> {
+ role = "tool"
+ messageParam.javaClass.getMethod("asTool").invoke(messageParam)
+ }
+ else -> null
+ }
+ if (actualMessage != null) {
+ try {
+ val contentResult =
+ actualMessage.javaClass.getMethod("content").invoke(actualMessage)
+ if (contentResult != null) {
+ try {
+ val textResult =
+ contentResult.javaClass
+ .getMethod("text")
+ .invoke(contentResult)
+ content =
+ when (textResult) {
+ is java.util.Optional<*> ->
+ (textResult as java.util.Optional<*>)
+ .orElse(null)
+ ?.toString()
+ is String -> textResult
+ else -> null
+ }
+ } catch (_: NoSuchMethodException) {
+ content =
+ when (contentResult) {
+ is String -> contentResult
+ is java.util.Optional<*> ->
+ (contentResult as java.util.Optional<*>)
+ .orElse(null)
+ ?.toString()
+ else -> null
+ }
+ }
+ }
+ } catch (_: NoSuchMethodException) {}
+ }
+ if (role == null) {
+ val roleResult =
+ messageParam.javaClass.getMethod("role").invoke(messageParam)
+ role = roleResult?.toString()?.lowercase()
+ }
+ } catch (_: Exception) {}
+ if (role == null) {
+ val className = messageParam.javaClass.simpleName
+ val fullName = messageParam.javaClass.name
+ role =
+ when {
+ "User" in className || "User" in fullName -> "user"
+ "System" in className || "System" in fullName -> "system"
+ "Assistant" in className || "Assistant" in fullName -> "assistant"
+ "Tool" in className || "Tool" in fullName -> "tool"
+ else -> null
+ }
+ }
+ if (content == null) {
+ content = extractContentFromToString(messageParam.toString(), role)
+ }
+ if (role != null) json.append("\"role\":\"").append(role).append("\"")
+ if (content != null) {
+ if (role != null) json.append(",")
+ json
+ .append("\"content\":\"")
+ .append(TracingUtils.escapeJsonString(content))
+ .append("\"")
+ }
+ json.append("}")
+ }
+ json.append("]")
+ return json.toString()
+ }
+
+ private fun extractContentFromToString(messageStr: String, role: String?): String? {
+ if (role == "tool") {
+ val toolContentPattern = Pattern.compile("text=\\{([^}]+)\\}")
+ val toolMatcher = toolContentPattern.matcher(messageStr)
+ if (toolMatcher.find()) return "{" + toolMatcher.group(1) + "}"
+ val simplePattern = Pattern.compile("text=[\"']([^\"']+)[\"']")
+ val simpleMatcher = simplePattern.matcher(messageStr)
+ if (simpleMatcher.find()) return simpleMatcher.group(1)
+ }
+ var contentIdx = messageStr.indexOf("content=")
+ if (contentIdx >= 0) {
+ val textIdx = messageStr.indexOf("text=", contentIdx)
+ if (textIdx > contentIdx) {
+ val start1 = messageStr.indexOf("\"", textIdx)
+ if (start1 >= 0) {
+ var end = start1 + 1
+ while (end < messageStr.length && messageStr[end] != '"') {
+ if (messageStr[end] == '\\') end += 2 else end++
+ }
+ if (end < messageStr.length) return messageStr.substring(start1 + 1, end)
+ }
+ }
+ val start2 = messageStr.indexOf("\"", contentIdx)
+ if (start2 >= 0) {
+ val end = messageStr.indexOf("\"", start2 + 1)
+ if (end > start2) return messageStr.substring(start2 + 1, end)
+ }
+ }
+ val pattern = Pattern.compile("content[=:]\\s*[\"']([^\"']+)[\"']")
+ val matcher = pattern.matcher(messageStr)
+ if (matcher.find()) return matcher.group(1)
+ val fallback = Pattern.compile("[\"']([^\"']+)[\"']").matcher(messageStr)
+ if (fallback.find()) {
+ val potential = fallback.group(1)
+ if (
+ !potential.contains("com.openai") &&
+ potential !in listOf("user", "system", "assistant", "tool") &&
+ potential.isNotEmpty()
+ )
+ return potential
+ }
+ return null
+ }
+
+ private fun formatOutputMessages(completion: ChatCompletion): String {
+ if (completion.choices().isEmpty()) return "[]"
+ val json = StringBuilder("[")
+ var first = true
+ for (choice in completion.choices()) {
+ if (!first) json.append(",")
+ first = false
+ val message = choice.message()
+ json.append("{\"role\":\"assistant\"")
+ message.content().ifPresent {
+ json
+ .append(",\"content\":\"")
+ .append(TracingUtils.escapeJsonString(it))
+ .append("\"")
+ }
+ message.toolCalls().ifPresent { toolCalls ->
+ if (toolCalls.isNotEmpty()) {
+ json.append(",\"tool_calls\":[")
+ var firstTc = true
+ for (toolCall in toolCalls) {
+ if (!toolCall.isFunction()) continue
+ if (!firstTc) json.append(",")
+ firstTc = false
+ val fn = toolCall.asFunction()
+ json
+ .append("{\"id\":\"")
+ .append(TracingUtils.escapeJsonString(fn.id()))
+ .append("\"")
+ json.append(",\"type\":\"function\"")
+ json
+ .append(",\"function\":{\"name\":\"")
+ .append(TracingUtils.escapeJsonString(fn.function().name()))
+ json
+ .append("\",\"arguments\":\"")
+ .append(TracingUtils.escapeJsonString(fn.function().arguments()))
+ .append("\"}")
+ json.append("}")
+ }
+ json.append("]")
+ }
+ }
+ json.append("}")
+ }
+ json.append("]")
+ return json.toString()
+ }
+
+ private fun extractPromptFromParams(params: ChatCompletionCreateParams): String? {
+ for (messageParam in params.messages()) {
+ try {
+ if (
+ messageParam.javaClass.getMethod("isUser").invoke(messageParam) as Boolean
+ ) {
+ val userMessage =
+ messageParam.javaClass.getMethod("asUser").invoke(messageParam)
+ val content = userMessage.javaClass.getMethod("content").invoke(userMessage)
+ when (content) {
+ is String -> return content
+ is List<*> ->
+ if (content.isNotEmpty())
+ (content[0] as? String)?.let {
+ return it
+ }
+ }
+ }
+ } catch (_: Exception) {}
+ }
+ return null
+ }
+
+ private fun extractCompletionFromResult(result: ChatCompletion): String? =
+ result.choices().firstOrNull()?.message()?.content()?.orElse(null)
+
+ override fun create(
+ params: StructuredChatCompletionCreateParams
+ ): StructuredChatCompletion = createStructured(params, null)
+
+ override fun create(
+ params: StructuredChatCompletionCreateParams,
+ requestOptions: RequestOptions,
+ ): StructuredChatCompletion = createStructured(params, requestOptions)
+
+ private fun createStructured(
+ params: StructuredChatCompletionCreateParams,
+ requestOptions: RequestOptions?,
+ ): StructuredChatCompletion {
+ val model = params.rawParams?.model()?.toString()
+ val span = TracingUtils.createSpanBuilder(model, "chat").startSpan()
+ try {
+ span.makeCurrent().use {
+ setExperimentContextAttributes(span)
+ TracingUtils.setRequestAttributes(span, model)
+ params.rawParams?.let { raw ->
+ TracingUtils.setRequestParameters(
+ span,
+ raw.temperature().orElse(null),
+ raw.topP().orElse(null),
+ raw.maxCompletionTokens().orElse(null),
+ )
+ formatInputMessages(raw).let { TracingUtils.setInputMessages(span, it) }
+ }
+ val result =
+ if (requestOptions == null) delegate.create(params)
+ else delegate.create(params, requestOptions!!)
+ TracingUtils.setResponseMetadata(span, model, null)
+ result.usage().ifPresent { u ->
+ TracingUtils.setResponseAttributes(
+ span,
+ u.promptTokens().toLong(),
+ u.completionTokens().toLong(),
+ u.totalTokens().toLong(),
+ )
+ }
+ return result
+ }
+ } catch (e: Exception) {
+ TracingUtils.recordException(span, e)
+ throw e
+ } finally {
+ span.end()
+ }
+ }
+
+ override fun createStreaming(
+ params: ChatCompletionCreateParams
+ ): StreamResponse = createStreamingChat(params, null)
+
+ override fun createStreaming(
+ params: ChatCompletionCreateParams,
+ requestOptions: RequestOptions,
+ ): StreamResponse = createStreamingChat(params, requestOptions)
+
+ private fun createStreamingChat(
+ params: ChatCompletionCreateParams,
+ requestOptions: RequestOptions?,
+ ): StreamResponse {
+ val model = params.model()?.toString()
+ val span = TracingUtils.createSpanBuilder(model, "chat").startSpan()
+ try {
+ span.makeCurrent().use {
+ setExperimentContextAttributes(span)
+ TracingUtils.setRequestAttributes(span, model)
+ span.setAttribute(AttributeKey.booleanKey("gen_ai.streaming"), true)
+ TracingUtils.setRequestParameters(
+ span,
+ params.temperature().orElse(null),
+ params.topP().orElse(null),
+ params.maxCompletionTokens().orElse(null),
+ )
+ formatInputMessages(params).let { TracingUtils.setInputMessages(span, it) }
+ return if (requestOptions == null) delegate.createStreaming(params)
+ else delegate.createStreaming(params, requestOptions!!)
+ }
+ } catch (e: Exception) {
+ TracingUtils.recordException(span, e)
+ throw e
+ } finally {
+ span.end()
+ }
+ }
+
+ override fun retrieve(completionId: String) = delegate.retrieve(completionId)
+
+ override fun retrieve(
+ completionId: String,
+ params: com.openai.models.chat.completions.ChatCompletionRetrieveParams,
+ ) = delegate.retrieve(completionId, params)
+
+ override fun retrieve(completionId: String, requestOptions: RequestOptions) =
+ delegate.retrieve(completionId, requestOptions)
+
+ override fun retrieve(
+ completionId: String,
+ params: com.openai.models.chat.completions.ChatCompletionRetrieveParams,
+ requestOptions: RequestOptions,
+ ) = delegate.retrieve(completionId, params, requestOptions)
+
+ override fun retrieve(
+ params: com.openai.models.chat.completions.ChatCompletionRetrieveParams
+ ) = delegate.retrieve(params)
+
+ override fun retrieve(
+ params: com.openai.models.chat.completions.ChatCompletionRetrieveParams,
+ requestOptions: RequestOptions,
+ ) = delegate.retrieve(params, requestOptions)
+
+ override fun update(
+ completionId: String,
+ params: com.openai.models.chat.completions.ChatCompletionUpdateParams,
+ ) = delegate.update(completionId, params)
+
+ override fun update(
+ completionId: String,
+ params: com.openai.models.chat.completions.ChatCompletionUpdateParams,
+ requestOptions: RequestOptions,
+ ) = delegate.update(completionId, params, requestOptions)
+
+ override fun update(params: com.openai.models.chat.completions.ChatCompletionUpdateParams) =
+ delegate.update(params)
+
+ override fun update(
+ params: com.openai.models.chat.completions.ChatCompletionUpdateParams,
+ requestOptions: RequestOptions,
+ ) = delegate.update(params, requestOptions)
+
+ override fun list() = delegate.list()
+
+ override fun list(requestOptions: RequestOptions) = delegate.list(requestOptions)
+
+ override fun list(params: com.openai.models.chat.completions.ChatCompletionListParams) =
+ delegate.list(params)
+
+ override fun list(
+ params: com.openai.models.chat.completions.ChatCompletionListParams,
+ requestOptions: RequestOptions,
+ ) = delegate.list(params, requestOptions)
+
+ override fun delete(completionId: String) = delegate.delete(completionId)
+
+ override fun delete(completionId: String, requestOptions: RequestOptions) =
+ delegate.delete(completionId, requestOptions)
+
+ override fun delete(
+ completionId: String,
+ params: com.openai.models.chat.completions.ChatCompletionDeleteParams,
+ ) = delegate.delete(completionId, params)
+
+ override fun delete(
+ completionId: String,
+ params: com.openai.models.chat.completions.ChatCompletionDeleteParams,
+ requestOptions: RequestOptions,
+ ) = delegate.delete(completionId, params, requestOptions)
+
+ override fun delete(params: com.openai.models.chat.completions.ChatCompletionDeleteParams) =
+ delegate.delete(params)
+
+ override fun delete(
+ params: com.openai.models.chat.completions.ChatCompletionDeleteParams,
+ requestOptions: RequestOptions,
+ ) = delegate.delete(params, requestOptions)
+ }
+}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.kt
new file mode 100644
index 00000000..0ef75826
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.kt
@@ -0,0 +1,104 @@
+package com.langchain.smith.wrappers.openai
+
+import com.openai.client.OpenAIClient
+import com.openai.client.okhttp.OpenAIOkHttpClient
+import com.openai.core.ClientOptions
+import com.openai.services.blocking.ChatService
+import com.openai.services.blocking.ResponseService
+import java.util.function.Consumer
+
+/**
+ * Wrapped OpenAI client that maintains the same developer experience as the original client while
+ * adding LangSmith tracing capabilities.
+ *
+ * This wrapper delegates all calls to the underlying OpenAI client.
+ */
+class WrappedOpenAIClient(private val delegate: OpenAIClient) : OpenAIClient {
+
+ fun getDelegate(): OpenAIClient = delegate
+
+ override fun async() = delegate.async()
+
+ override fun withRawResponse(): OpenAIClient.WithRawResponse = delegate.withRawResponse()
+
+ override fun withOptions(options: Consumer) =
+ delegate.withOptions(options)
+
+ override fun completions() = delegate.completions()
+
+ override fun chat(): ChatService = WrappedChatService(delegate.chat())
+
+ override fun embeddings() = delegate.embeddings()
+
+ override fun files() = delegate.files()
+
+ override fun images() = delegate.images()
+
+ override fun audio() = delegate.audio()
+
+ override fun moderations() = delegate.moderations()
+
+ override fun models() = delegate.models()
+
+ override fun fineTuning() = delegate.fineTuning()
+
+ override fun graders() = delegate.graders()
+
+ override fun vectorStores() = delegate.vectorStores()
+
+ override fun webhooks() = delegate.webhooks()
+
+ override fun beta() = delegate.beta()
+
+ override fun batches() = delegate.batches()
+
+ override fun uploads() = delegate.uploads()
+
+ override fun responses(): ResponseService = WrappedResponseService(delegate.responses())
+
+ override fun realtime() = delegate.realtime()
+
+ override fun conversations() = delegate.conversations()
+
+ override fun evals() = delegate.evals()
+
+ override fun containers() = delegate.containers()
+
+ override fun videos() = delegate.videos()
+
+ override fun close() = delegate.close()
+
+ class Builder {
+ private val delegateBuilder = OpenAIOkHttpClient.builder()
+
+ fun fromEnv() = apply { delegateBuilder.fromEnv() }
+
+ fun apiKey(apiKey: String) = apply { delegateBuilder.apiKey(apiKey) }
+
+ fun organization(organization: String) = apply {
+ delegateBuilder.organization(organization)
+ }
+
+ fun project(project: String) = apply { delegateBuilder.project(project) }
+
+ fun webhookSecret(webhookSecret: String) = apply {
+ delegateBuilder.webhookSecret(webhookSecret)
+ }
+
+ fun baseUrl(baseUrl: String) = apply { delegateBuilder.baseUrl(baseUrl) }
+
+ fun build(): WrappedOpenAIClient = WrappedOpenAIClient(delegateBuilder.build())
+ }
+
+ companion object {
+ @JvmStatic fun builder(): Builder = Builder()
+
+ @JvmStatic
+ fun wrap(client: OpenAIClient): WrappedOpenAIClient {
+ require(client != null) { "client cannot be null" }
+ return WrappedOpenAIClient(client)
+ }
+
+ @JvmStatic fun fromEnv(): WrappedOpenAIClient = builder().fromEnv().build()
+ }
+}
diff --git a/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedResponseService.kt b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedResponseService.kt
new file mode 100644
index 00000000..cf2ee28f
--- /dev/null
+++ b/langsmith-java-core/src/main/kotlin/com/langchain/smith/wrappers/openai/WrappedResponseService.kt
@@ -0,0 +1,351 @@
+package com.langchain.smith.wrappers.openai
+
+import com.openai.core.ClientOptions
+import com.openai.core.RequestOptions
+import com.openai.core.http.StreamResponse
+import com.openai.models.responses.Response
+import com.openai.models.responses.ResponseCreateParams
+import com.openai.models.responses.ResponseStreamEvent
+import com.openai.models.responses.StructuredResponse
+import com.openai.models.responses.StructuredResponseCreateParams
+import com.openai.services.blocking.ResponseService
+import io.opentelemetry.api.common.AttributeKey
+import io.opentelemetry.api.trace.Span
+import java.util.function.Consumer
+
+/** Wrapped ResponseService that adds OpenTelemetry tracing to response operations. */
+internal class WrappedResponseService(private val delegate: ResponseService) : ResponseService {
+
+ override fun withRawResponse() = delegate.withRawResponse()
+
+ override fun withOptions(options: Consumer) =
+ WrappedResponseService(delegate.withOptions(options))
+
+ override fun inputItems() = delegate.inputItems()
+
+ override fun inputTokens() = delegate.inputTokens()
+
+ override fun create(): Response = createResponse(null, null)
+
+ override fun create(requestOptions: RequestOptions): Response =
+ createResponse(null, requestOptions)
+
+ override fun create(params: ResponseCreateParams): Response = createResponse(params, null)
+
+ override fun create(params: ResponseCreateParams, requestOptions: RequestOptions): Response =
+ createResponse(params, requestOptions)
+
+ private fun createResponse(
+ params: ResponseCreateParams?,
+ requestOptions: RequestOptions?,
+ ): Response {
+ val model =
+ if (params != null && params.model().isPresent) params.model().toString() else null
+ val span = TracingUtils.createSpanBuilder(model, "response").startSpan()
+ try {
+ span.makeCurrent().use {
+ setExperimentContextAttributes(span)
+ TracingUtils.setRequestAttributes(span, model)
+ if (params != null) {
+ TracingUtils.setRequestParameters(
+ span,
+ params.temperature().orElse(null),
+ params.topP().orElse(null),
+ null,
+ )
+ }
+ val result =
+ when {
+ params == null && requestOptions == null -> delegate.create()
+ params == null -> delegate.create(requestOptions!!)
+ requestOptions == null -> delegate.create(params)
+ else -> delegate.create(params, requestOptions)
+ }
+ TracingUtils.setResponseMetadata(span, model, null)
+ result.usage().ifPresent { usage ->
+ TracingUtils.setResponseAttributes(
+ span,
+ usage.inputTokens().toLong(),
+ usage.outputTokens().toLong(),
+ usage.totalTokens().toLong(),
+ )
+ }
+ result.status()?.let {
+ span.setAttribute(
+ AttributeKey.stringKey("gen_ai.response.status"),
+ it.toString(),
+ )
+ }
+ return result
+ }
+ } catch (e: Exception) {
+ TracingUtils.recordException(span, e)
+ throw e
+ } finally {
+ span.end()
+ }
+ }
+
+ override fun create(
+ params: StructuredResponseCreateParams
+ ): StructuredResponse = createStructured(params, null)
+
+ override fun create(
+ params: StructuredResponseCreateParams,
+ requestOptions: RequestOptions,
+ ): StructuredResponse = createStructured(params, requestOptions)
+
+ private fun createStructured(
+ params: StructuredResponseCreateParams,
+ requestOptions: RequestOptions?,
+ ): StructuredResponse {
+ val model =
+ if (params.rawParams != null && params.rawParams!!.model() != null)
+ params.rawParams!!.model().toString()
+ else null
+ val span = TracingUtils.createSpanBuilder(model, "response").startSpan()
+ try {
+ span.makeCurrent().use {
+ setExperimentContextAttributes(span)
+ TracingUtils.setRequestAttributes(span, model)
+ if (params.rawParams != null) {
+ val raw = params.rawParams!!
+ TracingUtils.setRequestParameters(
+ span,
+ raw.temperature().orElse(null),
+ raw.topP().orElse(null),
+ null,
+ )
+ }
+ val result =
+ if (requestOptions == null) delegate.create(params)
+ else delegate.create(params, requestOptions)
+ TracingUtils.setResponseMetadata(span, model, null)
+ result.usage().ifPresent { usage ->
+ TracingUtils.setResponseAttributes(
+ span,
+ usage.inputTokens().toLong(),
+ usage.outputTokens().toLong(),
+ usage.totalTokens().toLong(),
+ )
+ }
+ return result
+ }
+ } catch (e: Exception) {
+ TracingUtils.recordException(span, e)
+ throw e
+ } finally {
+ span.end()
+ }
+ }
+
+ override fun createStreaming(): StreamResponse =
+ createStreamingResponse(null, null)
+
+ override fun createStreaming(
+ requestOptions: RequestOptions
+ ): StreamResponse = createStreamingResponse(null, requestOptions)
+
+ override fun createStreaming(
+ params: ResponseCreateParams
+ ): StreamResponse = createStreamingResponse(params, null)
+
+ override fun createStreaming(
+ params: ResponseCreateParams,
+ requestOptions: RequestOptions,
+ ): StreamResponse = createStreamingResponse(params, requestOptions)
+
+ private fun createStreamingResponse(
+ params: ResponseCreateParams?,
+ requestOptions: RequestOptions?,
+ ): StreamResponse {
+ val model =
+ if (params != null && params.model() != null) params.model().toString() else null
+ val span = TracingUtils.createSpanBuilder(model, "response").startSpan()
+ try {
+ span.makeCurrent().use {
+ setExperimentContextAttributes(span)
+ TracingUtils.setRequestAttributes(span, model)
+ span.setAttribute(AttributeKey.booleanKey("gen_ai.streaming"), true)
+ if (params != null) {
+ TracingUtils.setRequestParameters(
+ span,
+ params.temperature().orElse(null),
+ params.topP().orElse(null),
+ null,
+ )
+ }
+ return when {
+ params == null && requestOptions == null -> delegate.createStreaming()
+ params == null -> delegate.createStreaming(requestOptions!!)
+ requestOptions == null -> delegate.createStreaming(params)
+ else -> delegate.createStreaming(params, requestOptions)
+ }
+ }
+ } catch (e: Exception) {
+ TracingUtils.recordException(span, e)
+ throw e
+ } finally {
+ span.end()
+ }
+ }
+
+ override fun createStreaming(
+ params: StructuredResponseCreateParams<*>
+ ): StreamResponse = createStreamingStructured(params, null)
+
+ override fun createStreaming(
+ params: StructuredResponseCreateParams<*>,
+ requestOptions: RequestOptions,
+ ): StreamResponse = createStreamingStructured(params, requestOptions)
+
+ private fun createStreamingStructured(
+ params: StructuredResponseCreateParams<*>?,
+ requestOptions: RequestOptions?,
+ ): StreamResponse {
+ val model =
+ if (params != null && params.rawParams != null && params.rawParams!!.model() != null)
+ params.rawParams!!.model().toString()
+ else null
+ val span = TracingUtils.createSpanBuilder(model, "response").startSpan()
+ try {
+ span.makeCurrent().use {
+ setExperimentContextAttributes(span)
+ TracingUtils.setRequestAttributes(span, model)
+ span.setAttribute(AttributeKey.booleanKey("gen_ai.streaming"), true)
+ if (params != null && params.rawParams != null) {
+ val raw = params.rawParams!!
+ TracingUtils.setRequestParameters(
+ span,
+ raw.temperature().orElse(null),
+ raw.topP().orElse(null),
+ null,
+ )
+ }
+ return when {
+ requestOptions == null -> delegate.createStreaming(params!!)
+ else -> delegate.createStreaming(params!!, requestOptions)
+ }
+ }
+ } catch (e: Exception) {
+ TracingUtils.recordException(span, e)
+ throw e
+ } finally {
+ span.end()
+ }
+ }
+
+ private fun setExperimentContextAttributes(span: Span) {
+ ExperimentContext.current()
+ .getReferenceExampleId()
+ .filter { it.isNotEmpty() }
+ .ifPresent {
+ span.setAttribute(AttributeKey.stringKey("langsmith.reference_example_id"), it)
+ }
+ ExperimentContext.current()
+ .getSessionId()
+ .filter { it.isNotEmpty() }
+ .ifPresent {
+ span.setAttribute(AttributeKey.stringKey("langsmith.trace.session_id"), it)
+ }
+ for ((key, value) in ExperimentContext.current().getMetadata()) {
+ span.setAttribute(AttributeKey.stringKey("langsmith.metadata.$key"), value)
+ }
+ }
+
+ override fun retrieve(responseId: String) = delegate.retrieve(responseId)
+
+ override fun retrieve(responseId: String, requestOptions: RequestOptions) =
+ delegate.retrieve(responseId, requestOptions)
+
+ override fun retrieve(
+ responseId: String,
+ params: com.openai.models.responses.ResponseRetrieveParams,
+ ) = delegate.retrieve(responseId, params)
+
+ override fun retrieve(
+ responseId: String,
+ params: com.openai.models.responses.ResponseRetrieveParams,
+ requestOptions: RequestOptions,
+ ) = delegate.retrieve(responseId, params, requestOptions)
+
+ override fun retrieve(params: com.openai.models.responses.ResponseRetrieveParams) =
+ delegate.retrieve(params)
+
+ override fun retrieve(
+ params: com.openai.models.responses.ResponseRetrieveParams,
+ requestOptions: RequestOptions,
+ ) = delegate.retrieve(params, requestOptions)
+
+ override fun retrieveStreaming(responseId: String) = delegate.retrieveStreaming(responseId)
+
+ override fun retrieveStreaming(responseId: String, requestOptions: RequestOptions) =
+ delegate.retrieveStreaming(responseId, requestOptions)
+
+ override fun retrieveStreaming(
+ responseId: String,
+ params: com.openai.models.responses.ResponseRetrieveParams,
+ ) = delegate.retrieveStreaming(responseId, params)
+
+ override fun retrieveStreaming(
+ responseId: String,
+ params: com.openai.models.responses.ResponseRetrieveParams,
+ requestOptions: RequestOptions,
+ ) = delegate.retrieveStreaming(responseId, params, requestOptions)
+
+ override fun retrieveStreaming(params: com.openai.models.responses.ResponseRetrieveParams) =
+ delegate.retrieveStreaming(params)
+
+ override fun retrieveStreaming(
+ params: com.openai.models.responses.ResponseRetrieveParams,
+ requestOptions: RequestOptions,
+ ) = delegate.retrieveStreaming(params, requestOptions)
+
+ override fun delete(responseId: String) = delegate.delete(responseId)
+
+ override fun delete(responseId: String, requestOptions: RequestOptions) =
+ delegate.delete(responseId, requestOptions)
+
+ override fun delete(
+ responseId: String,
+ params: com.openai.models.responses.ResponseDeleteParams,
+ ) = delegate.delete(responseId, params)
+
+ override fun delete(
+ responseId: String,
+ params: com.openai.models.responses.ResponseDeleteParams,
+ requestOptions: RequestOptions,
+ ) = delegate.delete(responseId, params, requestOptions)
+
+ override fun delete(params: com.openai.models.responses.ResponseDeleteParams) =
+ delegate.delete(params)
+
+ override fun delete(
+ params: com.openai.models.responses.ResponseDeleteParams,
+ requestOptions: RequestOptions,
+ ) = delegate.delete(params, requestOptions)
+
+ override fun cancel(responseId: String) = delegate.cancel(responseId)
+
+ override fun cancel(responseId: String, requestOptions: RequestOptions) =
+ delegate.cancel(responseId, requestOptions)
+
+ override fun cancel(
+ responseId: String,
+ params: com.openai.models.responses.ResponseCancelParams,
+ ) = delegate.cancel(responseId, params)
+
+ override fun cancel(
+ responseId: String,
+ params: com.openai.models.responses.ResponseCancelParams,
+ requestOptions: RequestOptions,
+ ) = delegate.cancel(responseId, params, requestOptions)
+
+ override fun cancel(params: com.openai.models.responses.ResponseCancelParams) =
+ delegate.cancel(params)
+
+ override fun cancel(
+ params: com.openai.models.responses.ResponseCancelParams,
+ requestOptions: RequestOptions,
+ ) = delegate.cancel(params, requestOptions)
+}
diff --git a/langsmith-java-example/README.md b/langsmith-java-example/README.md
index 97993b21..8dcb41b5 100644
--- a/langsmith-java-example/README.md
+++ b/langsmith-java-example/README.md
@@ -1,8 +1,8 @@
-# LangSmith Java Examples
+# LangSmith Examples
-This module contains runnable examples organized by feature:
-- **`otel/`** - OpenTelemetry tracing examples
-- **`prompt/`** - Prompt management examples
+This module contains runnable Kotlin examples organized by feature:
+- **`example/`** - SDK examples (ListRuns, Dataset, PromptManagement, RecordExperiment, E2eEval)
+- **`example/otel/`** - OpenTelemetry tracing examples
## Prerequisites
@@ -20,22 +20,7 @@ The `langchain.baseUrl` system property (or `LANGSMITH_ENDPOINT` environment var
## OpenTelemetry Tracing Examples
-Located in `src/main/java/com/langchain/smith/example/otel/`
-
-### Jaeger (Local)
-
-Send traces to local Jaeger instance.
-
-```bash
-# Start Jaeger
-docker run -d --name jaeger -p 4318:4318 -p 16686:16686 jaegertracing/all-in-one:latest
-
-# Run example
-./gradlew :langsmith-java-example:run -Pexample=OtelJaeger
-
-# View traces
-open http://localhost:16686
-```
+Located in `src/main/kotlin/com/langchain/smith/example/otel/`
### OpenAI + LangSmith (Real API Calls)
@@ -80,9 +65,9 @@ curl -X POST http://localhost:8080/api/chat \
curl "http://localhost:8080/api/analyze?text=This%20is%20great"
```
-## Prompt Management Examples
+## Prompt Management Example
-Located in `src/main/java/com/langchain/smith/example/prompt/`
+Located in `src/main/kotlin/com/langchain/smith/example/`
### Prompt Management (Getting Started)
diff --git a/langsmith-java-example/build.gradle.kts b/langsmith-java-example/build.gradle.kts
index 799e8a78..a7e4cf4a 100644
--- a/langsmith-java-example/build.gradle.kts
+++ b/langsmith-java-example/build.gradle.kts
@@ -1,7 +1,7 @@
plugins {
- id("langchain.java")
application
kotlin("jvm")
+ id("org.jetbrains.kotlin.plugin.spring") version "2.0.21"
id("org.springframework.boot") version "2.7.18" apply false
}
@@ -9,6 +9,12 @@ repositories {
mavenCentral()
}
+// Align with Kotlin JVM target (Kotlin plugin applies Java plugin; keep targets consistent)
+java {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+}
+
dependencies {
implementation(project(":langsmith-java"))
implementation(kotlin("stdlib"))
@@ -22,61 +28,58 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter")
}
-tasks.withType().configureEach {
- // Allow using more modern APIs, like `List.of` and `Map.of`, in examples.
- options.release.set(9)
-}
-
tasks.withType().configureEach {
compilerOptions {
- jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_9)
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}
application {
- // Use `./gradlew :langsmith-java-example:run` to run `Main`
- // Use `./gradlew :langsmith-java-example:run -Pexample=Something` to run `SomethingExample`
+ // Require -Pexample=Name to run an example (e.g. -Pexample=ListRuns, -Pexample=OtelLangSmith)
mainClass = if (project.hasProperty("example")) {
- val exampleName = project.property("example") as String
+ var exampleName = project.property("example") as String
+ val aliases = mapOf(
+ "OtelLangSmithSimple" to "OtelLangSmith",
+ "PromptManagmentExample" to "PromptManagement",
+ "PromptManagment" to "PromptManagement",
+ )
+ exampleName = aliases[exampleName] ?: exampleName
val baseName = if (exampleName.endsWith("Example")) exampleName else "${exampleName}Example"
-
- // Search in multiple subdirectories: root, otel, prompt
val searchPaths = listOf(
"" to "com.langchain.smith.example",
- "otel/" to "com.langchain.smith.example.otel",
- "prompt/" to "com.langchain.smith.example.prompt"
+ "otel/" to "com.langchain.smith.example.otel"
)
-
var foundPackage = ""
- var isKotlin = false
-
for ((subdir, packageName) in searchPaths) {
- val javaFile = file("src/main/java/com/langchain/smith/example/${subdir}${baseName}.java")
val kotlinFile = file("src/main/kotlin/com/langchain/smith/example/${subdir}${baseName}.kt")
-
- if (javaFile.exists()) {
+ if (kotlinFile.exists()) {
foundPackage = packageName
- isKotlin = false
- break
- } else if (kotlinFile.exists()) {
- foundPackage = packageName
- isKotlin = true
break
}
}
-
if (foundPackage.isNotEmpty()) {
- "${foundPackage}.${baseName}${if (isKotlin) "Kt" else ""}"
+ "${foundPackage}.${baseName}Kt"
} else {
- // Default: assume Kotlin in root for backwards compatibility
- "com.langchain.smith.example.${baseName}Kt"
+ throw GradleException(
+ "Example '$exampleName' not found. No ${baseName}.kt in " +
+ "src/main/kotlin/.../example/ or .../example/otel/. " +
+ "Use -Pexample=ListRuns, -Pexample=OtelLangSmith, -Pexample=OtelLangSmithSimple, -Pexample=OtelOpenAI, etc."
+ )
}
} else {
- "Main"
+ "Main" // placeholder; run task doFirst will require -Pexample=
}
}
-// Export stdin to examples for readln()
+// Export stdin to examples for readln(); require -Pexample= when running (configuration-cache safe: no project access in doFirst)
tasks.named("run") {
standardInput = System.`in`
+ doFirst {
+ if (mainClass.get() == "Main") {
+ throw GradleException(
+ "Example module requires -Pexample=ExampleName. " +
+ "e.g. ./gradlew :langsmith-java-example:run -Pexample=ListRuns"
+ )
+ }
+ }
}
diff --git a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelJaegerExample.java b/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelJaegerExample.java
deleted file mode 100644
index 2c0ed0bc..00000000
--- a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelJaegerExample.java
+++ /dev/null
@@ -1,117 +0,0 @@
-package com.langchain.smith.example.otel;
-
-import com.langchain.smith.otel.OtelSpanCreator;
-import com.langchain.smith.otel.OtelTraceExporter;
-import io.opentelemetry.api.common.AttributeKey;
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.api.trace.StatusCode;
-import io.opentelemetry.api.trace.Tracer;
-import io.opentelemetry.context.Scope;
-import java.time.Duration;
-
-/**
- * Example: Send live OpenTelemetry traces to Jaeger.
- *
- * Start Jaeger: docker run -d --name jaeger -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest
- * Run: ./gradlew :langsmith-java-example:run -Pexample=OtelJaegerExample
- * View: http://localhost:16686
- */
-public class OtelJaegerExample {
- public static void main(String[] args) throws Exception {
- System.out.println("=== LangSmith to Jaeger Example ===\n");
-
- OtelTraceExporter exporter = OtelTraceExporter.builder()
- .endpoint("http://localhost:4318/v1/traces")
- .enabled(true)
- .timeout(Duration.ofSeconds(10))
- .serviceName("langsmith-java-example")
- .build();
-
- Tracer tracer = exporter.getTracer();
- String projectName = exporter.getProjectName();
-
- System.out.println("Creating waterfall trace with 5 spans...\n");
-
- // ROOT SPAN: Main chain
- Span rootSpan = OtelSpanCreator.createChainSpan(tracer, "langchain.chain", projectName, null);
- try (Scope rootScope = rootSpan.makeCurrent()) {
- System.out.println("→ Root span: langchain.chain started");
-
- // CHILD 1: First LLM call
- Span llmSpan1 = OtelSpanCreator.createLlmSpan(tracer, "openai.chat", "openai", "gpt-4", projectName, null);
- try (Scope scope = llmSpan1.makeCurrent()) {
- OtelSpanCreator.setInput(llmSpan1, "What's the weather?");
-
- System.out.println(" → Child span 1: openai.chat started");
- Thread.sleep(500);
-
- OtelSpanCreator.setOutput(llmSpan1, "I'll check the weather for you.");
- OtelSpanCreator.setTokenUsage(llmSpan1, 10, 8);
- llmSpan1.setStatus(StatusCode.OK);
- System.out.println(" ← Child span 1: openai.chat completed");
- } finally {
- llmSpan1.end();
- }
-
- // CHILD 2: Tool call
- Span toolSpan = OtelSpanCreator.createToolSpan(tracer, "weather.tool", "get_weather", projectName, null);
- try (Scope scope = toolSpan.makeCurrent()) {
-
- System.out.println(" → Child span 2: weather.tool started");
- Thread.sleep(300);
- toolSpan.setStatus(StatusCode.OK);
- System.out.println(" ← Child span 2: weather.tool completed");
- } finally {
- toolSpan.end();
- }
-
- // CHILD 3: Second LLM call with nested database query
- Span llmSpan2 = OtelSpanCreator.createLlmSpan(tracer, "openai.chat", "openai", "gpt-4", projectName, null);
- try (Scope scope2 = llmSpan2.makeCurrent()) {
- OtelSpanCreator.setInput(llmSpan2, "Provide a detailed weather summary.");
-
- System.out.println(" → Child span 3: openai.chat started");
-
- // NESTED CHILD: Database query
- Span dbSpan =
- OtelSpanCreator.createToolSpan(tracer, "database.query", "postgresql_query", projectName, null);
- try (Scope dbScope = dbSpan.makeCurrent()) {
- dbSpan.setAttribute(AttributeKey.stringKey("db.system"), "postgresql");
- OtelSpanCreator.setInput(dbSpan, "SELECT * FROM weather_data WHERE city='SF'");
-
- System.out.println(" → Nested span: database.query started");
- Thread.sleep(200);
-
- // Simulate error
- dbSpan.setStatus(StatusCode.ERROR, "Connection timeout");
- dbSpan.setAttribute(AttributeKey.booleanKey("error"), true);
- dbSpan.setAttribute(AttributeKey.stringKey("error.type"), "timeout");
-
- System.out.println(" ← Nested span: database.query failed");
- } finally {
- dbSpan.end();
- }
-
- Thread.sleep(400);
- OtelSpanCreator.setOutput(llmSpan2, "Unable to retrieve detailed data due to database error.");
- OtelSpanCreator.setTokenUsage(llmSpan2, 20, 15);
- llmSpan2.setStatus(StatusCode.OK);
- System.out.println(" ← Child span 3: openai.chat completed");
- } finally {
- llmSpan2.end();
- }
-
- rootSpan.setStatus(StatusCode.OK);
- System.out.println("← Root span: langchain.chain completed");
- } finally {
- rootSpan.end();
- }
-
- System.out.println("\nFlushing to Jaeger...");
- exporter.flush().join(10, java.util.concurrent.TimeUnit.SECONDS);
- Thread.sleep(6000);
- exporter.shutdown().join(5, java.util.concurrent.TimeUnit.SECONDS);
-
- System.out.println("\n✓ Complete! View at: http://localhost:16686");
- }
-}
diff --git a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelLangSmithExample.java b/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelLangSmithExample.java
deleted file mode 100644
index 1d8c9b58..00000000
--- a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelLangSmithExample.java
+++ /dev/null
@@ -1,161 +0,0 @@
-package com.langchain.smith.example.otel;
-
-import com.langchain.smith.otel.OtelConfig;
-import com.langchain.smith.otel.OtelSpanCreator;
-import com.langchain.smith.otel.OtelTraceExporter;
-import io.opentelemetry.api.common.AttributeKey;
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.api.trace.StatusCode;
-import io.opentelemetry.api.trace.Tracer;
-import io.opentelemetry.context.Scope;
-import java.time.Duration;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.UUID;
-
-/**
- * Example: Send OpenTelemetry traces to LangSmith UI.
- *
- * This is a mock/demo example that simulates LLM calls without requiring API keys.
- * It demonstrates the tracing structure and waterfall visualization.
- *
- * Usage:
- * export LANGSMITH_API_KEY=your_api_key
- * ./gradlew :langsmith-java-example:run -Pexample=OtelLangSmith
- */
-public class OtelLangSmithExample {
- public static void main(String[] args) throws Exception {
- System.out.println("=== LangSmith OpenTelemetry Example ===\n");
-
- // Get LangSmith API key
- String apiKey = System.getenv("LANGSMITH_API_KEY");
- if (apiKey == null || apiKey.isEmpty()) {
- apiKey = System.getProperty("langsmith.api.key");
- }
- if (apiKey == null || apiKey.isEmpty()) {
- System.err.println(
- "ERROR: LANGSMITH_API_KEY environment variable or langsmith.api.key system property is required!");
- return;
- }
-
- String projectName = System.getenv("LANGSMITH_PROJECT");
- if (projectName == null || projectName.isEmpty()) {
- projectName = System.getProperty("langsmith.project.name", "default");
- }
-
- System.out.println("Configuration:");
- System.out.println(" Endpoint: https://api.smith.langchain.com/otel/v1/traces");
- System.out.println(" Project: " + projectName);
- System.out.println(" Service name: langsmith-java");
- System.out.println();
-
- // Configure the exporter for LangSmith
- Map headers = new HashMap<>();
- headers.put("x-api-key", apiKey);
- headers.put("Langsmith-Project", projectName);
-
- OtelConfig config = OtelConfig.builder()
- .enabled(true)
- .endpoint("https://api.smith.langchain.com/otel/v1/traces")
- .headers(headers)
- .timeout(Duration.ofSeconds(30))
- .serviceName("langsmith-java")
- .build();
-
- OtelTraceExporter exporter = OtelTraceExporter.fromConfig(config);
- Tracer tracer = exporter.getTracer();
-
- // Create a session ID for grouping
- String sessionId = UUID.randomUUID().toString();
-
- System.out.println("Creating waterfall with 5 spans:");
- System.out.println(" 1. agent.chain (root, 2s)");
- System.out.println(" ├─ 2. openai.llm (500ms)");
- System.out.println(" ├─ 3. weather.tool (300ms)");
- System.out.println(" └─ 4. openai.llm (600ms)");
- System.out.println(" └─ 5. database.retriever (200ms)\n");
-
- // ROOT SPAN: Main agent chain
- String initialPrompt = "What's the weather in San Francisco?";
- Span rootSpan = OtelSpanCreator.createChainSpan(tracer, "langsmith.java.example", projectName, sessionId);
-
- try (Scope rootScope = rootSpan.makeCurrent()) {
- // Set input on root span
- OtelSpanCreator.setInput(rootSpan, initialPrompt);
- // CHILD 1: First LLM call
- Span llmSpan1 =
- OtelSpanCreator.createLlmSpan(tracer, "openai.llm.call", "openai", "gpt-4", projectName, sessionId);
- try (Scope llmScope1 = llmSpan1.makeCurrent()) {
- OtelSpanCreator.setInput(llmSpan1, "What's the weather in San Francisco?");
- Thread.sleep(500);
- OtelSpanCreator.setOutput(llmSpan1, "Let me check the weather for you.");
- OtelSpanCreator.setTokenUsage(llmSpan1, 15, 12);
- llmSpan1.setStatus(StatusCode.OK);
- } finally {
- llmSpan1.end();
- }
-
- // CHILD 2: Tool call
- String toolInput = "{\"location\":\"San Francisco\"}";
- String toolOutput = "{\"temperature\":\"72°F\",\"condition\":\"Sunny\",\"humidity\":\"65%\"}";
- Span toolSpan =
- OtelSpanCreator.createToolSpan(tracer, "weather.tool", "get_weather", projectName, sessionId);
- try (Scope toolScope = toolSpan.makeCurrent()) {
- // Set tool input using gen_ai.prompt
- OtelSpanCreator.setInput(toolSpan, toolInput);
- // Set tool arguments attribute
- toolSpan.setAttribute(AttributeKey.stringKey("gen_ai.tool.arguments"), toolInput);
- Thread.sleep(300);
- // Set tool output using gen_ai.completion
- OtelSpanCreator.setOutput(toolSpan, toolOutput);
- toolSpan.setStatus(StatusCode.OK);
- } finally {
- toolSpan.end();
- }
-
- // CHILD 3: Second LLM call with nested retriever
- Span llmSpan2 = OtelSpanCreator.createLlmSpan(
- tracer, "openai.llm.final", "openai", "gpt-4", projectName, sessionId);
- try (Scope llmScope2 = llmSpan2.makeCurrent()) {
- OtelSpanCreator.setInput(llmSpan2, "Based on the weather data, provide a summary.");
-
- // NESTED CHILD: Retriever call inside LLM
- Span retrieverSpan =
- OtelSpanCreator.createRetrievalSpan(tracer, "database.retriever", projectName, sessionId);
- try (Scope retrieverScope = retrieverSpan.makeCurrent()) {
- OtelSpanCreator.setInput(retrieverSpan, "weather forecast data");
- Thread.sleep(200);
- OtelSpanCreator.setOutput(retrieverSpan, "Temperature: 72F, Sunny");
- retrieverSpan.setStatus(StatusCode.OK);
- } finally {
- retrieverSpan.end();
- }
-
- Thread.sleep(400);
- OtelSpanCreator.setOutput(
- llmSpan2, "The weather in San Francisco is sunny with a temperature of 72°F.");
- OtelSpanCreator.setTokenUsage(llmSpan2, 25, 18);
- llmSpan2.setStatus(StatusCode.OK);
- } finally {
- llmSpan2.end();
- }
-
- // Set output on root span
- String finalOutput = "The weather in San Francisco is sunny with a temperature of 72°F.";
- OtelSpanCreator.setOutput(rootSpan, finalOutput);
- rootSpan.setStatus(StatusCode.OK);
-
- } finally {
- rootSpan.end();
- }
-
- System.out.println("\nAll spans ended. Flushing to LangSmith...");
-
- // Force flush to send the span immediately
- exporter.flush().join(10, java.util.concurrent.TimeUnit.SECONDS);
-
- // Wait for batch to be sent
- Thread.sleep(6000);
- exporter.shutdown().join(5, java.util.concurrent.TimeUnit.SECONDS);
- }
-}
diff --git a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelOpenAIExample.java b/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelOpenAIExample.java
deleted file mode 100644
index feecefe8..00000000
--- a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/OtelOpenAIExample.java
+++ /dev/null
@@ -1,325 +0,0 @@
-package com.langchain.smith.example.otel;
-
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.langchain.smith.wrappers.openai.OpenTelemetryConfig;
-import com.langchain.smith.wrappers.openai.WrappedOpenAIClient;
-import com.openai.core.JsonValue;
-import com.openai.models.ChatModel;
-import com.openai.models.FunctionDefinition;
-import com.openai.models.FunctionParameters;
-import com.openai.models.chat.completions.ChatCompletion;
-import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam;
-import com.openai.models.chat.completions.ChatCompletionCreateParams;
-import com.openai.models.chat.completions.ChatCompletionFunctionTool;
-import com.openai.models.chat.completions.ChatCompletionMessage;
-import com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall;
-import com.openai.models.chat.completions.ChatCompletionMessageParam;
-import com.openai.models.chat.completions.ChatCompletionMessageToolCall;
-import com.openai.models.chat.completions.ChatCompletionTool;
-import com.openai.models.chat.completions.ChatCompletionToolChoiceOption;
-import com.openai.models.chat.completions.ChatCompletionToolMessageParam;
-import io.opentelemetry.api.OpenTelemetry;
-import io.opentelemetry.api.common.AttributeKey;
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.api.trace.SpanKind;
-import io.opentelemetry.api.trace.StatusCode;
-import io.opentelemetry.api.trace.Tracer;
-import io.opentelemetry.context.Scope;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Example: Make real OpenAI API calls with OpenTelemetry tracing to LangSmith.
- *
- * This example demonstrates:
- *
- * - Configuring OpenTelemetry to send traces to LangSmith
- * - Using the wrapped OpenAI client for automatic tracing
- * - Making actual API calls to OpenAI with tool definitions
- * - Automatic tool call span creation
- * - Multi-turn conversations with tool execution
- * - Viewing rich traces in the LangSmith dashboard
- *
- *
- * Usage:
- *
- * export OPENAI_API_KEY=your_openai_api_key
- * export LANGSMITH_API_KEY=your_langsmith_api_key
- * export LANGSMITH_PROJECT=your_project_name
- * ./gradlew :langsmith-java-example:run -Pexample=OtelOpenAI
- *
- */
-public class OtelOpenAIExample {
- private static final String SEPARATOR = "============================================================";
-
- public static void main(String[] args) {
- System.out.println("=== OpenAI + LangSmith OpenTelemetry Example ===\n");
-
- // Check for required environment variables
- String openaiKey = System.getenv("OPENAI_API_KEY");
- if (openaiKey == null || openaiKey.isEmpty()) {
- System.err.println("ERROR: OPENAI_API_KEY environment variable is required!");
- System.err.println("Get your API key from: https://platform.openai.com/api-keys");
- return;
- }
-
- String langsmithKey = System.getenv("LANGSMITH_API_KEY");
- if (langsmithKey == null || langsmithKey.isEmpty()) {
- System.err.println("ERROR: LANGSMITH_API_KEY environment variable is required!");
- System.err.println("Get your API key from: https://smith.langchain.com/settings");
- return;
- }
-
- String projectName = System.getenv("LANGSMITH_PROJECT");
- if (projectName == null || projectName.isEmpty()) {
- projectName = "default";
- }
-
- System.out.println("Configuration:");
- System.out.println(" LangSmith Project: " + projectName);
- System.out.println(" Service Name: langsmith-java-openai-example");
- System.out.println();
-
- // Configure OpenTelemetry to send traces to LangSmith
- // Using SIMPLE processor for immediate export (best for short-lived examples)
- try {
- OpenTelemetryConfig.builder()
- .apiKey(langsmithKey)
- .projectName(projectName)
- .serviceName("langsmith-java-openai-example")
- .processorType(OpenTelemetryConfig.SpanProcessorType.SIMPLE)
- .maxBatchSize(1)
- .build();
- System.out.println("✓ OpenTelemetry configured for LangSmith\n");
- } catch (Exception e) {
- System.err.println("✗ Failed to configure OpenTelemetry: " + e.getMessage());
- e.printStackTrace();
- return;
- }
-
- // Create wrapped OpenAI client - all calls will automatically be traced
- WrappedOpenAIClient client = WrappedOpenAIClient.fromEnv();
-
- // Create a parent span to wrap the workflow
- OpenTelemetry openTelemetry = io.opentelemetry.api.GlobalOpenTelemetry.get();
- Tracer tracer = openTelemetry.getTracer("langsmith-java-openai-example");
-
- Span workflowSpan = tracer.spanBuilder("openai_agent_workflow")
- .setSpanKind(SpanKind.INTERNAL)
- .setAttribute("gen_ai.operation.name", "agent_workflow")
- .setAttribute("langsmith.span.kind", "chain")
- .setAttribute("langsmith.trace.name", "OpenAI Agent with Tools")
- .startSpan();
-
- try (Scope scope = workflowSpan.makeCurrent()) {
- System.out.println(SEPARATOR);
- System.out.println("Agent Workflow: Chat with Tool Calls");
- System.out.println(SEPARATOR);
-
- // Build tool (function) definition for weather API
- Map properties = new HashMap<>();
- Map locationProperty = new HashMap<>();
- locationProperty.put("type", JsonValue.from("string"));
- locationProperty.put("description", JsonValue.from("The city and state, e.g., San Francisco, CA"));
- properties.put("location", JsonValue.from(locationProperty));
-
- Map parametersJson = new HashMap<>();
- parametersJson.put("type", JsonValue.from("object"));
- parametersJson.put("properties", JsonValue.from(properties));
- parametersJson.put("required", JsonValue.from(Arrays.asList("location")));
-
- // Create initial request with tool definitions
- String initialUserMessage = "What is the capital of France and what's the current weather there?";
- ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
- .model(ChatModel.GPT_4O_MINI)
- .addUserMessage(initialUserMessage)
- .tools(Arrays.asList(ChatCompletionTool.ofFunction(ChatCompletionFunctionTool.builder()
- .function(FunctionDefinition.builder()
- .name("get_weather")
- .description("Get the current weather for a given location")
- .parameters(FunctionParameters.builder()
- .putAllAdditionalProperties(parametersJson)
- .build())
- .build())
- .build())))
- .toolChoice(
- ChatCompletionToolChoiceOption.Companion.ofAuto(ChatCompletionToolChoiceOption.Auto.AUTO))
- .build();
-
- // Set input on workflow span
- workflowSpan.setAttribute(
- io.opentelemetry.api.common.AttributeKey.stringKey("gen_ai.prompt"), initialUserMessage);
-
- System.out.println("\n1. Making initial API call with tool definitions...");
- ChatCompletion completion = client.chat().completions().create(params);
-
- // Check if the response contains tool calls
- ChatCompletionMessage message = completion.choices().get(0).message();
- java.util.Optional> toolCallsOpt = message.toolCalls();
-
- String finalContent;
-
- if (toolCallsOpt.isPresent() && !toolCallsOpt.get().isEmpty()) {
- System.out.println(" ✓ Tool calls detected in response!");
- List toolCalls = toolCallsOpt.get();
-
- // Build messages list for follow-up request
- List messages = new ArrayList<>();
- messages.add(params.messages().get(0)); // Original user message
-
- // Add assistant message with tool calls
- messages.add(ChatCompletionMessageParam.ofAssistant(ChatCompletionAssistantMessageParam.builder()
- .content(message.content().orElse(""))
- .toolCalls(toolCalls)
- .build()));
-
- // Execute each tool call
- System.out.println("\n2. Executing tool calls...");
- for (ChatCompletionMessageToolCall toolCall : toolCalls) {
- if (toolCall.isFunction()) {
- ChatCompletionMessageFunctionToolCall functionToolCall = toolCall.asFunction();
- String toolName = functionToolCall.function().name();
- String toolArguments = functionToolCall.function().arguments();
- String toolCallId = functionToolCall.id();
-
- System.out.println(" - Tool: " + toolName + " | Args: " + toolArguments);
-
- // Create a tool execution span to capture the tool execution and result
- Span toolExecutionSpan = tracer.spanBuilder("tool_execution " + toolName)
- .setSpanKind(SpanKind.INTERNAL)
- .setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "tool")
- .setAttribute(AttributeKey.stringKey("gen_ai.tool.name"), toolName)
- .setAttribute(AttributeKey.stringKey("gen_ai.tool.call.id"), toolCallId)
- .setAttribute(AttributeKey.stringKey("gen_ai.tool.arguments"), toolArguments)
- .setAttribute(AttributeKey.stringKey("langsmith.span.kind"), "tool")
- .setAttribute(AttributeKey.stringKey("gen_ai.prompt"), toolArguments)
- .startSpan();
-
- String toolResult;
- try (Scope toolExecutionScope = toolExecutionSpan.makeCurrent()) {
- // Execute the tool (simulated weather API)
- toolResult = executeTool(toolName, toolArguments);
- System.out.println(" - Result: " + toolResult);
-
- // Set tool execution result as output
- toolExecutionSpan.setAttribute(AttributeKey.stringKey("gen_ai.completion"), toolResult);
- toolExecutionSpan.setStatus(StatusCode.OK);
- } catch (Exception e) {
- toolExecutionSpan.recordException(e);
- toolExecutionSpan.setStatus(StatusCode.ERROR);
- toolResult = "{\"error\": \"" + e.getMessage() + "\"}";
- } finally {
- toolExecutionSpan.end();
- }
-
- // Add tool result message
- messages.add(ChatCompletionMessageParam.ofTool(ChatCompletionToolMessageParam.builder()
- .toolCallId(functionToolCall.id())
- .content(toolResult)
- .build()));
- }
- }
-
- // Send follow-up request with tool results
- System.out.println("\n3. Sending follow-up request with tool results...");
- ChatCompletionCreateParams followUpParams = ChatCompletionCreateParams.builder()
- .model(ChatModel.GPT_4O_MINI)
- .messages(messages)
- .build();
-
- completion = client.chat().completions().create(followUpParams);
- finalContent = completion.choices().get(0).message().content().orElse("No content");
- } else {
- finalContent = message.content().orElse("No content");
- }
-
- // Display final response
- System.out.println("\n" + SEPARATOR);
- System.out.println("Final Response:");
- System.out.println(finalContent);
- System.out.println(SEPARATOR);
-
- // Display token usage
- completion.usage().ifPresent(usage -> {
- System.out.println("\nTotal Token Usage:");
- System.out.println(" Input: " + usage.promptTokens());
- System.out.println(" Output: " + usage.completionTokens());
- System.out.println(" Total: " + usage.totalTokens());
- });
-
- // Set output on workflow span
- workflowSpan.setAttribute(
- io.opentelemetry.api.common.AttributeKey.stringKey("gen_ai.completion"), finalContent);
- workflowSpan.setAttribute("response.content", finalContent);
- workflowSpan.setStatus(io.opentelemetry.api.trace.StatusCode.OK);
-
- } catch (Exception e) {
- System.err.println("\n✗ Error during API call: " + e.getMessage());
- e.printStackTrace();
- workflowSpan.recordException(e);
- workflowSpan.setStatus(io.opentelemetry.api.trace.StatusCode.ERROR);
- } finally {
- workflowSpan.end();
- }
-
- // Close the client
- client.close();
-
- // Flush traces to ensure they're sent to LangSmith
- System.out.println("\n" + SEPARATOR);
- System.out.println("Flushing traces to LangSmith...");
- boolean flushed = OpenTelemetryConfig.flush(10, java.util.concurrent.TimeUnit.SECONDS);
-
- if (flushed) {
- System.out.println("✓ Traces sent successfully!");
- System.out.println("\nView your traces at:");
- System.out.println(" https://smith.langchain.com/projects/" + projectName);
- } else {
- System.err.println("✗ Warning: Flush may not have completed successfully");
- }
-
- System.out.println(SEPARATOR);
- System.out.println("\nNote: Check the trace waterfall in LangSmith UI to see:");
- System.out.println(" - Parent workflow span (chain)");
- System.out.println(" - Child LLM spans (automatically created)");
- System.out.println(" - Tool call spans (automatically created by wrapper)");
- }
-
- /**
- * Simulates executing a tool based on its name and arguments.
- *
- * @param toolName the name of the tool to execute
- * @param arguments JSON string containing the tool arguments
- * @return JSON string containing the tool result
- */
- private static String executeTool(String toolName, String arguments) {
- try {
- ObjectMapper mapper = new ObjectMapper();
- JsonNode args = mapper.readTree(arguments);
-
- if ("get_weather".equals(toolName)) {
- String location = args.has("location") ? args.get("location").asText() : "unknown";
-
- // Simulate weather API call
- Map result = new HashMap<>();
- result.put("location", location);
- result.put("temperature", "18°C");
- result.put("condition", "Partly Cloudy");
- result.put("humidity", "65%");
- result.put("wind", "15 km/h");
-
- return mapper.writeValueAsString(result);
- } else {
- Map errorMap = new HashMap<>();
- errorMap.put("error", "Unknown tool: " + toolName);
- return mapper.writeValueAsString(errorMap);
- }
- } catch (Exception e) {
- return "{\"error\": \"" + e.getMessage() + "\"}";
- }
- }
-}
diff --git a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/SpringBootLangSmithExample.java b/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/SpringBootLangSmithExample.java
deleted file mode 100644
index a77ccae9..00000000
--- a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/SpringBootLangSmithExample.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.langchain.smith.example.otel;
-
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
-
-/**
- * Spring Boot example: Send OpenTelemetry traces to LangSmith.
- *
- * Usage:
- * export LANGSMITH_API_KEY=your_api_key
- * export LANGSMITH_PROJECT=my-project # optional, defaults to "default"
- * ./gradlew :langsmith-java-example:run -Pexample=SpringBootLangSmith
- *
- * Then make requests to:
- * http://localhost:8080/api/chat
- * http://localhost:8080/api/analyze?text=hello
- */
-@SpringBootApplication
-public class SpringBootLangSmithExample {
-
- public static void main(String[] args) {
- System.out.println("=== Spring Boot + LangSmith OpenTelemetry Example ===\n");
-
- // Check required environment variables
- String apiKey = System.getenv("LANGSMITH_API_KEY");
- if (apiKey == null || apiKey.isEmpty()) {
- System.err.println("ERROR: LANGSMITH_API_KEY environment variable is required!");
- System.err.println("\nUsage:");
- System.err.println(" export LANGSMITH_API_KEY=your_api_key_here");
- System.err.println(" export LANGSMITH_PROJECT=my-project # optional");
- System.err.println(" ./gradlew :langsmith-java-example:run -Pexample=SpringBootLangSmith");
- System.exit(1);
- }
-
- String projectName = System.getenv("LANGSMITH_PROJECT");
- if (projectName == null || projectName.isEmpty()) {
- projectName = "default";
- }
-
- System.out.println("Configuration:");
- System.out.println(" Project: " + projectName);
- System.out.println(" Endpoint: https://api.smith.langchain.com/otel/v1/traces");
- System.out.println("\nStarting Spring Boot application...");
- System.out.println("Try these endpoints:");
- System.out.println(" POST http://localhost:8080/api/chat");
- System.out.println(" GET http://localhost:8080/api/analyze?text=hello");
- System.out.println();
-
- SpringApplication.run(SpringBootLangSmithExample.class, args);
- }
-}
diff --git a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/config/OtelConfiguration.java b/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/config/OtelConfiguration.java
deleted file mode 100644
index 2c3371d3..00000000
--- a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/config/OtelConfiguration.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.langchain.smith.example.otel.config;
-
-import com.langchain.smith.otel.OtelConfig;
-import com.langchain.smith.otel.OtelTraceExporter;
-import io.opentelemetry.api.trace.Tracer;
-import java.time.Duration;
-import java.util.HashMap;
-import java.util.Map;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
-/**
- * Spring configuration for OpenTelemetry integration with LangSmith.
- */
-@Configuration
-public class OtelConfiguration {
-
- @Bean
- public OtelTraceExporter otelTraceExporter() {
- String apiKey = System.getenv("LANGSMITH_API_KEY");
- String projectName = System.getenv("LANGSMITH_PROJECT");
- if (projectName == null || projectName.isEmpty()) {
- projectName = "default";
- }
-
- Map headers = new HashMap<>();
- headers.put("x-api-key", apiKey);
- headers.put("Langsmith-Project", projectName);
-
- OtelConfig config = OtelConfig.builder()
- .enabled(true)
- .endpoint("https://api.smith.langchain.com/otel/v1/traces")
- .headers(headers)
- .timeout(Duration.ofSeconds(30))
- .serviceName("spring-boot-langsmith")
- .build();
-
- return OtelTraceExporter.fromConfig(config);
- }
-
- @Bean
- public Tracer tracer(OtelTraceExporter exporter) {
- return exporter.getTracer();
- }
-}
diff --git a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/config/OtelShutdownHook.java b/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/config/OtelShutdownHook.java
deleted file mode 100644
index 30a43826..00000000
--- a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/config/OtelShutdownHook.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.langchain.smith.example.otel.config;
-
-import com.langchain.smith.otel.OtelTraceExporter;
-import javax.annotation.PreDestroy;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Component;
-
-/**
- * Ensures OpenTelemetry traces are flushed on application shutdown.
- */
-@Component
-public class OtelShutdownHook {
-
- private final OtelTraceExporter exporter;
-
- @Autowired
- public OtelShutdownHook(OtelTraceExporter exporter) {
- this.exporter = exporter;
- }
-
- @PreDestroy
- public void onShutdown() {
- System.out.println("\n→ Flushing OpenTelemetry traces...");
- try {
- exporter.flush().join(10000, java.util.concurrent.TimeUnit.MILLISECONDS);
- System.out.println("✓ Traces flushed successfully");
- } catch (Exception e) {
- System.err.println("✗ Failed to flush traces: " + e.getMessage());
- }
- }
-}
diff --git a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/controller/ChatController.java b/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/controller/ChatController.java
deleted file mode 100644
index 29058f6f..00000000
--- a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/controller/ChatController.java
+++ /dev/null
@@ -1,105 +0,0 @@
-package com.langchain.smith.example.otel.controller;
-
-import com.langchain.smith.example.otel.service.LlmService;
-import com.langchain.smith.otel.OtelSpanCreator;
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.api.trace.StatusCode;
-import io.opentelemetry.api.trace.Tracer;
-import io.opentelemetry.context.Scope;
-import java.util.Map;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.*;
-
-/**
- * REST controller demonstrating OpenTelemetry tracing with LangSmith.
- */
-@RestController
-@RequestMapping("/api")
-public class ChatController {
-
- private final Tracer tracer;
- private final LlmService llmService;
-
- @Autowired
- public ChatController(Tracer tracer, LlmService llmService) {
- this.tracer = tracer;
- this.llmService = llmService;
- }
-
- @PostMapping("/chat")
- public Map chat(@RequestBody Map request) {
- String userMessage = request.getOrDefault("message", "Hello!");
-
- // Create a root span for the entire request
- Span rootSpan = OtelSpanCreator.createChainSpan(tracer, "chat_request", "spring-boot-langsmith", null);
-
- try (Scope scope = rootSpan.makeCurrent()) {
- OtelSpanCreator.setInput(rootSpan, userMessage);
-
- System.out.println("→ Processing chat request: " + userMessage);
-
- // Call the LLM service (which creates its own span)
- String response = llmService.generateResponse(userMessage);
-
- OtelSpanCreator.setOutput(rootSpan, response);
- rootSpan.setStatus(StatusCode.OK);
-
- System.out.println("← Chat response generated");
-
- return Map.of(
- "request",
- userMessage,
- "response",
- response,
- "model",
- "gpt-4",
- "trace_id",
- rootSpan.getSpanContext().getTraceId());
-
- } catch (Exception e) {
- rootSpan.setStatus(StatusCode.ERROR, e.getMessage());
- throw e;
- } finally {
- rootSpan.end();
- }
- }
-
- @GetMapping("/analyze")
- public Map analyze(@RequestParam String text) {
- // Create a span for the analysis operation
- Span analysisSpan = OtelSpanCreator.createChainSpan(tracer, "text_analysis", "spring-boot-langsmith", null);
-
- try (Scope scope = analysisSpan.makeCurrent()) {
- OtelSpanCreator.setInput(analysisSpan, text);
-
- System.out.println("→ Analyzing text: " + text);
-
- // Simulate analysis with nested operations
- int wordCount = text.split("\\s+").length;
- String sentiment = llmService.analyzeSentiment(text);
-
- String result = String.format("Word count: %d, Sentiment: %s", wordCount, sentiment);
- OtelSpanCreator.setOutput(analysisSpan, result);
- analysisSpan.setStatus(StatusCode.OK);
-
- System.out.println("← Analysis complete");
-
- return Map.of(
- "text", text,
- "word_count", wordCount,
- "sentiment", sentiment,
- "trace_id", analysisSpan.getSpanContext().getTraceId());
-
- } catch (Exception e) {
- analysisSpan.setStatus(StatusCode.ERROR, e.getMessage());
- throw e;
- } finally {
- analysisSpan.end();
- }
- }
-
- @GetMapping("/health")
- public Map health() {
- return Map.of("status", "healthy", "service", "spring-boot-langsmith");
- }
-}
diff --git a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/service/LlmService.java b/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/service/LlmService.java
deleted file mode 100644
index db9bb261..00000000
--- a/langsmith-java-example/src/main/java/com/langchain/smith/example/otel/service/LlmService.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package com.langchain.smith.example.otel.service;
-
-import com.langchain.smith.otel.OtelSpanCreator;
-import io.opentelemetry.api.trace.Span;
-import io.opentelemetry.api.trace.StatusCode;
-import io.opentelemetry.api.trace.Tracer;
-import io.opentelemetry.context.Scope;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
-
-/**
- * Service layer demonstrating nested OpenTelemetry spans.
- */
-@Service
-public class LlmService {
-
- private final Tracer tracer;
-
- @Autowired
- public LlmService(Tracer tracer) {
- this.tracer = tracer;
- }
-
- /**
- * Simulates an LLM API call with tracing.
- */
- public String generateResponse(String input) {
- Span llmSpan =
- OtelSpanCreator.createLlmSpan(tracer, "openai.chat", "openai", "gpt-4", "spring-boot-langsmith", null);
-
- try (Scope scope = llmSpan.makeCurrent()) {
- OtelSpanCreator.setInput(llmSpan, input);
-
- System.out.println(" → Calling OpenAI API...");
-
- // Simulate LLM processing time
- Thread.sleep(500);
-
- String response = "I received your message: '" + input + "'. How can I help you today?";
-
- OtelSpanCreator.setOutput(llmSpan, response);
- OtelSpanCreator.setTokenUsage(llmSpan, 15, 20);
- llmSpan.setStatus(StatusCode.OK);
-
- System.out.println(" ← OpenAI API response received");
-
- return response;
-
- } catch (Exception e) {
- llmSpan.setStatus(StatusCode.ERROR, e.getMessage());
- throw new RuntimeException("LLM call failed", e);
- } finally {
- llmSpan.end();
- }
- }
-
- /**
- * Simulates sentiment analysis with tracing.
- */
- public String analyzeSentiment(String text) {
- Span sentimentSpan = OtelSpanCreator.createLlmSpan(
- tracer, "sentiment_analysis", "openai", "gpt-4", "spring-boot-langsmith", null);
-
- try (Scope scope = sentimentSpan.makeCurrent()) {
- OtelSpanCreator.setInput(sentimentSpan, text);
-
- System.out.println(" → Analyzing sentiment...");
-
- // Simulate analysis time
- Thread.sleep(300);
-
- // Simple sentiment detection
- String sentiment;
- if (text.toLowerCase().contains("good") || text.toLowerCase().contains("great")) {
- sentiment = "positive";
- } else if (text.toLowerCase().contains("bad") || text.toLowerCase().contains("terrible")) {
- sentiment = "negative";
- } else {
- sentiment = "neutral";
- }
-
- OtelSpanCreator.setOutput(sentimentSpan, sentiment);
- OtelSpanCreator.setTokenUsage(sentimentSpan, 8, 2);
- sentimentSpan.setStatus(StatusCode.OK);
-
- System.out.println(" ← Sentiment: " + sentiment);
-
- return sentiment;
-
- } catch (Exception e) {
- sentimentSpan.setStatus(StatusCode.ERROR, e.getMessage());
- throw new RuntimeException("Sentiment analysis failed", e);
- } finally {
- sentimentSpan.end();
- }
- }
-}
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/E2eEvalExample.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/E2eEvalExample.kt
index 9f00fb50..bac5ab02 100644
--- a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/E2eEvalExample.kt
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/E2eEvalExample.kt
@@ -1,6 +1,8 @@
package com.langchain.smith.example
import com.langchain.smith.client.LangsmithClient
+import com.langchain.smith.example.util.buildDatasetUrl
+import com.langchain.smith.example.util.generateExampleId
import com.langchain.smith.client.okhttp.LangsmithOkHttpClient
import com.langchain.smith.core.JsonValue
import com.langchain.smith.models.datasets.DatasetCreateParams
@@ -86,7 +88,7 @@ fun main() {
// Configure LangSmith client first (needed to create session)
val langsmithClient: LangsmithClient = LangsmithOkHttpClient.fromEnv()
- val datasetName = "Q&A Evaluation Dataset - Java Example"
+ val datasetName = "Q&A Evaluation Dataset - Kotlin Example"
val experimentName = "E2eEvalExample-${OffsetDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))}"
// Define test cases with questions and expected answers
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/PromptManagementExample.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/PromptManagementExample.kt
index 6d080d2e..89ce769d 100644
--- a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/PromptManagementExample.kt
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/PromptManagementExample.kt
@@ -11,7 +11,7 @@ import com.langchain.smith.models.repos.RepoListParams
import com.langchain.smith.models.repos.RepoWithLookups
/**
- * Demonstrates how to manage prompts programmatically using the LangSmith Java
+ * Demonstrates how to manage prompts programmatically using the LangSmith
* SDK.
*
* This example shows:
@@ -369,4 +369,3 @@ private fun extractPromptContent(manifestJson: JsonValue): String {
private fun getOwnerFromEnv(): String =
System.getenv("LANGSMITH_OWNER")?.takeIf { it.isNotEmpty() } ?: "-"
-
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/RecordExperimentExample.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/RecordExperimentExample.kt
index c9e0b41e..12a571cb 100644
--- a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/RecordExperimentExample.kt
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/RecordExperimentExample.kt
@@ -1,6 +1,9 @@
package com.langchain.smith.example
import com.langchain.smith.client.LangsmithClient
+import com.langchain.smith.example.util.buildDatasetUrl
+import com.langchain.smith.example.util.buildSessionUrl
+import com.langchain.smith.example.util.generateExampleId
import com.langchain.smith.client.okhttp.LangsmithOkHttpClient
import com.langchain.smith.core.JsonValue
import com.langchain.smith.models.datasets.Dataset
@@ -44,7 +47,7 @@ fun main() {
// Configure client from environment variables
val client: LangsmithClient = LangsmithOkHttpClient.fromEnv()
- val datasetName = "Experiment Dataset - Java Example"
+ val datasetName = "Experiment Dataset - Kotlin Example"
val experimentName = "My First Experiment - ${OffsetDateTime.now()}"
println("=".repeat(60))
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/OtelLangSmithExample.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/OtelLangSmithExample.kt
new file mode 100644
index 00000000..cdf7b1d3
--- /dev/null
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/OtelLangSmithExample.kt
@@ -0,0 +1,160 @@
+package com.langchain.smith.example.otel
+
+import com.langchain.smith.otel.OtelConfig
+import com.langchain.smith.otel.OtelSpanCreator
+import com.langchain.smith.otel.OtelTraceExporter
+import io.opentelemetry.api.common.AttributeKey
+import io.opentelemetry.api.trace.Span
+import io.opentelemetry.api.trace.StatusCode
+import io.opentelemetry.api.trace.Tracer
+import io.opentelemetry.context.Scope
+import java.time.Duration
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+import kotlin.system.exitProcess
+
+/**
+ * Example: Send OpenTelemetry traces to LangSmith UI.
+ *
+ * Mock/demo example that simulates LLM calls without requiring API keys.
+ * Demonstrates the tracing structure and waterfall visualization.
+ *
+ * Usage:
+ * export LANGSMITH_API_KEY=your_api_key
+ * ./gradlew :langsmith-java-example:run -Pexample=OtelLangSmith
+ */
+fun main() {
+ println("=== LangSmith OpenTelemetry Example ===\n")
+
+ var apiKey = System.getenv("LANGSMITH_API_KEY")
+ if (apiKey.isNullOrEmpty()) {
+ apiKey = System.getProperty("langsmith.api.key")
+ }
+ if (apiKey.isNullOrEmpty()) {
+ System.err.println(
+ "ERROR: LANGSMITH_API_KEY environment variable or langsmith.api.key system property is required!"
+ )
+ exitProcess(1)
+ }
+
+ var projectName = System.getenv("LANGSMITH_PROJECT")
+ if (projectName.isNullOrEmpty()) {
+ projectName = System.getProperty("langsmith.project.name", "default")
+ }
+
+ println("Configuration:")
+ println(" Endpoint: https://api.smith.langchain.com/otel/v1/traces")
+ println(" Project: $projectName")
+ println(" Service name: langsmith-kotlin")
+ println()
+
+ val headers = mapOf(
+ "x-api-key" to apiKey,
+ "Langsmith-Project" to projectName
+ )
+
+ val config = OtelConfig.builder()
+ .enabled(true)
+ .endpoint("https://api.smith.langchain.com/otel/v1/traces")
+ .headers(headers)
+ .timeout(Duration.ofSeconds(30))
+ .serviceName("langsmith-kotlin")
+ .build()
+
+ val exporter = OtelTraceExporter.fromConfig(config)
+ val tracer = exporter.tracer
+ val sessionId = UUID.randomUUID().toString()
+
+ println("Creating waterfall with 5 spans:")
+ println(" 1. agent.chain (root, 2s)")
+ println(" ├─ 2. openai.llm (500ms)")
+ println(" ├─ 3. weather.tool (300ms)")
+ println(" └─ 4. openai.llm (600ms)")
+ println(" └─ 5. database.retriever (200ms)\n")
+
+ val initialPrompt = "What's the weather in San Francisco?"
+ val rootSpan = OtelSpanCreator.createChainSpan(tracer, "langsmith.kotlin.example", projectName, sessionId)
+
+ try {
+ rootSpan.makeCurrent().use {
+ OtelSpanCreator.setInput(rootSpan, initialPrompt)
+
+ val llmSpan1 = OtelSpanCreator.createLlmSpan(
+ tracer, "openai.llm.call", "openai", "gpt-4", projectName, sessionId
+ )
+ try {
+ llmSpan1.makeCurrent().use {
+ OtelSpanCreator.setInput(llmSpan1, "What's the weather in San Francisco?")
+ Thread.sleep(500)
+ OtelSpanCreator.setOutput(llmSpan1, "Let me check the weather for you.")
+ OtelSpanCreator.setTokenUsage(llmSpan1, 15, 12)
+ llmSpan1.setStatus(StatusCode.OK)
+ }
+ } finally {
+ llmSpan1.end()
+ }
+
+ val toolInput = "{\"location\":\"San Francisco\"}"
+ val toolOutput = "{\"temperature\":\"72°F\",\"condition\":\"Sunny\",\"humidity\":\"65%\"}"
+ val toolSpan = OtelSpanCreator.createToolSpan(
+ tracer, "weather.tool", "get_weather", projectName, sessionId
+ )
+ try {
+ toolSpan.makeCurrent().use {
+ OtelSpanCreator.setInput(toolSpan, toolInput)
+ toolSpan.setAttribute(AttributeKey.stringKey("gen_ai.tool.arguments"), toolInput)
+ Thread.sleep(300)
+ OtelSpanCreator.setOutput(toolSpan, toolOutput)
+ toolSpan.setStatus(StatusCode.OK)
+ }
+ } finally {
+ toolSpan.end()
+ }
+
+ val llmSpan2 = OtelSpanCreator.createLlmSpan(
+ tracer, "openai.llm.final", "openai", "gpt-4", projectName, sessionId
+ )
+ try {
+ llmSpan2.makeCurrent().use {
+ OtelSpanCreator.setInput(llmSpan2, "Based on the weather data, provide a summary.")
+
+ val retrieverSpan = OtelSpanCreator.createRetrievalSpan(
+ tracer, "database.retriever", projectName, sessionId
+ )
+ try {
+ retrieverSpan.makeCurrent().use {
+ OtelSpanCreator.setInput(retrieverSpan, "weather forecast data")
+ Thread.sleep(200)
+ OtelSpanCreator.setOutput(retrieverSpan, "Temperature: 72F, Sunny")
+ retrieverSpan.setStatus(StatusCode.OK)
+ }
+ } finally {
+ retrieverSpan.end()
+ }
+
+ Thread.sleep(400)
+ OtelSpanCreator.setOutput(
+ llmSpan2,
+ "The weather in San Francisco is sunny with a temperature of 72°F."
+ )
+ OtelSpanCreator.setTokenUsage(llmSpan2, 25, 18)
+ llmSpan2.setStatus(StatusCode.OK)
+ }
+ } finally {
+ llmSpan2.end()
+ }
+
+ val finalOutput = "The weather in San Francisco is sunny with a temperature of 72°F."
+ OtelSpanCreator.setOutput(rootSpan, finalOutput)
+ rootSpan.setStatus(StatusCode.OK)
+ }
+ } finally {
+ rootSpan.end()
+ }
+
+ println("\nAll spans ended. Flushing to LangSmith...")
+
+ exporter.flush().join(10, TimeUnit.SECONDS)
+ Thread.sleep(6000)
+ exporter.shutdown().join(5, TimeUnit.SECONDS)
+}
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/OtelOpenAIExample.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/OtelOpenAIExample.kt
new file mode 100644
index 00000000..8775bb64
--- /dev/null
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/OtelOpenAIExample.kt
@@ -0,0 +1,285 @@
+package com.langchain.smith.example.otel
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.langchain.smith.wrappers.openai.OpenTelemetryConfig
+import com.langchain.smith.wrappers.openai.WrappedOpenAIClient
+import com.openai.models.ChatModel
+import com.openai.models.FunctionDefinition
+import com.openai.models.FunctionParameters
+import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam
+import com.openai.models.chat.completions.ChatCompletionCreateParams
+import com.openai.models.chat.completions.ChatCompletionFunctionTool
+import com.openai.models.chat.completions.ChatCompletionMessageParam
+import com.openai.models.chat.completions.ChatCompletionMessageToolCall
+import com.openai.models.chat.completions.ChatCompletionTool
+import com.openai.models.chat.completions.ChatCompletionToolChoiceOption
+import com.openai.models.chat.completions.ChatCompletionToolMessageParam
+import io.opentelemetry.api.OpenTelemetry
+import io.opentelemetry.api.common.AttributeKey
+import io.opentelemetry.api.trace.Span
+import io.opentelemetry.api.trace.SpanKind
+import io.opentelemetry.api.trace.StatusCode
+import io.opentelemetry.api.trace.Tracer
+import io.opentelemetry.context.Scope
+import java.util.concurrent.TimeUnit
+import kotlin.system.exitProcess
+
+/**
+ * Example: Make real OpenAI API calls with OpenTelemetry tracing to LangSmith.
+ *
+ * Demonstrates:
+ * - Configuring OpenTelemetry to send traces to LangSmith
+ * - Using the wrapped OpenAI client for automatic tracing
+ * - Making actual API calls to OpenAI with tool definitions
+ * - Automatic tool call span creation
+ * - Multi-turn conversations with tool execution
+ *
+ * Usage:
+ * export OPENAI_API_KEY=your_openai_api_key
+ * export LANGSMITH_API_KEY=your_langsmith_api_key
+ * export LANGSMITH_PROJECT=your_project_name
+ * ./gradlew :langsmith-java-example:run -Pexample=OtelOpenAI
+ */
+private const val SEPARATOR = "============================================================"
+
+fun main() {
+ println("=== OpenAI + LangSmith OpenTelemetry Example ===\n")
+
+ val openaiKey = System.getenv("OPENAI_API_KEY")
+ if (openaiKey.isNullOrEmpty()) {
+ System.err.println("ERROR: OPENAI_API_KEY environment variable is required!")
+ System.err.println("Get your API key from: https://platform.openai.com/api-keys")
+ exitProcess(1)
+ }
+
+ val langsmithKey = System.getenv("LANGSMITH_API_KEY")
+ if (langsmithKey.isNullOrEmpty()) {
+ System.err.println("ERROR: LANGSMITH_API_KEY environment variable is required!")
+ System.err.println("Get your API key from: https://smith.langchain.com/settings")
+ exitProcess(1)
+ }
+
+ val projectName = System.getenv("LANGSMITH_PROJECT") ?: "default"
+
+ println("Configuration:")
+ println(" LangSmith Project: $projectName")
+ println(" Service Name: langsmith-kotlin-openai-example")
+ println()
+
+ try {
+ OpenTelemetryConfig.builder()
+ .apiKey(langsmithKey)
+ .projectName(projectName)
+ .serviceName("langsmith-kotlin-openai-example")
+ .processorType(OpenTelemetryConfig.SpanProcessorType.SIMPLE)
+ .maxBatchSize(1)
+ .build()
+ println("✓ OpenTelemetry configured for LangSmith\n")
+ } catch (e: Exception) {
+ System.err.println("✗ Failed to configure OpenTelemetry: ${e.message}")
+ e.printStackTrace()
+ exitProcess(1)
+ }
+
+ val client = WrappedOpenAIClient.fromEnv()
+ val openTelemetry: OpenTelemetry = io.opentelemetry.api.GlobalOpenTelemetry.get()
+ val tracer: Tracer = openTelemetry.getTracer("langsmith-kotlin-openai-example")
+
+ val workflowSpan = tracer.spanBuilder("openai_agent_workflow")
+ .setSpanKind(SpanKind.INTERNAL)
+ .setAttribute("gen_ai.operation.name", "agent_workflow")
+ .setAttribute("langsmith.span.kind", "chain")
+ .setAttribute("langsmith.trace.name", "OpenAI Agent with Tools")
+ .startSpan()
+
+ try {
+ workflowSpan.makeCurrent().use { _ ->
+ val span = workflowSpan
+ println(SEPARATOR)
+ println("Agent Workflow: Chat with Tool Calls")
+ println(SEPARATOR)
+
+ val locationProperty = mapOf(
+ "type" to com.openai.core.JsonValue.from("string"),
+ "description" to com.openai.core.JsonValue.from("The city and state, e.g., San Francisco, CA")
+ )
+ val properties = mapOf("location" to com.openai.core.JsonValue.from(locationProperty))
+ val parametersJson = mapOf(
+ "type" to com.openai.core.JsonValue.from("object"),
+ "properties" to com.openai.core.JsonValue.from(properties),
+ "required" to com.openai.core.JsonValue.from(listOf("location"))
+ )
+
+ val initialUserMessage = "What is the capital of France and what's the current weather there?"
+ val params = ChatCompletionCreateParams.builder()
+ .model(ChatModel.GPT_4O_MINI)
+ .addUserMessage(initialUserMessage)
+ .tools(
+ listOf(
+ ChatCompletionTool.ofFunction(
+ ChatCompletionFunctionTool.builder()
+ .function(
+ FunctionDefinition.builder()
+ .name("get_weather")
+ .description("Get the current weather for a given location")
+ .parameters(FunctionParameters.builder().putAllAdditionalProperties(parametersJson).build())
+ .build()
+ )
+ .build()
+ )
+ )
+ )
+ .toolChoice(ChatCompletionToolChoiceOption.ofAuto(ChatCompletionToolChoiceOption.Auto.AUTO))
+ .build()
+
+ span.setAttribute(AttributeKey.stringKey("gen_ai.prompt"), initialUserMessage)
+
+ println("\n1. Making initial API call with tool definitions...")
+ var completion = client.chat().completions().create(params)
+ val message = completion.choices()[0].message()
+ val toolCallsOpt = message.toolCalls()
+
+ val finalContent = if (toolCallsOpt.isPresent && toolCallsOpt.get().isNotEmpty()) {
+ println(" ✓ Tool calls detected in response!")
+ val toolCalls = toolCallsOpt.get()
+ val messages = mutableListOf()
+ messages.add(params.messages()[0])
+ messages.add(
+ ChatCompletionMessageParam.ofAssistant(
+ ChatCompletionAssistantMessageParam.builder()
+ .content(message.content().orElse(""))
+ .toolCalls(toolCalls)
+ .build()
+ )
+ )
+
+ println("\n2. Executing tool calls...")
+ for (toolCall in toolCalls) {
+ if (toolCall.isFunction()) {
+ val functionToolCall = toolCall.asFunction()
+ val toolName = functionToolCall.function().name()
+ val toolArguments = functionToolCall.function().arguments()
+ val toolCallId = functionToolCall.id()
+
+ println(" - Tool: $toolName | Args: $toolArguments")
+
+ val toolExecutionSpan = tracer.spanBuilder("tool_execution $toolName")
+ .setSpanKind(SpanKind.INTERNAL)
+ .setAttribute(AttributeKey.stringKey("gen_ai.operation.name"), "tool")
+ .setAttribute(AttributeKey.stringKey("gen_ai.tool.name"), toolName)
+ .setAttribute(AttributeKey.stringKey("gen_ai.tool.call.id"), toolCallId)
+ .setAttribute(AttributeKey.stringKey("gen_ai.tool.arguments"), toolArguments)
+ .setAttribute(AttributeKey.stringKey("langsmith.span.kind"), "tool")
+ .setAttribute(AttributeKey.stringKey("gen_ai.prompt"), toolArguments)
+ .startSpan()
+
+ val toolResult = try {
+ toolExecutionSpan.makeCurrent().use {
+ val result = executeTool(toolName, toolArguments)
+ println(" - Result: $result")
+ toolExecutionSpan.setAttribute(AttributeKey.stringKey("gen_ai.completion"), result)
+ toolExecutionSpan.setStatus(StatusCode.OK)
+ result
+ }
+ } catch (e: Exception) {
+ toolExecutionSpan.recordException(e)
+ toolExecutionSpan.setStatus(StatusCode.ERROR)
+ "{\"error\": \"${e.message}\"}"
+ } finally {
+ toolExecutionSpan.end()
+ }
+
+ messages.add(
+ ChatCompletionMessageParam.ofTool(
+ ChatCompletionToolMessageParam.builder()
+ .toolCallId(functionToolCall.id())
+ .content(toolResult)
+ .build()
+ )
+ )
+ }
+ }
+
+ println("\n3. Sending follow-up request with tool results...")
+ val followUpParams = ChatCompletionCreateParams.builder()
+ .model(ChatModel.GPT_4O_MINI)
+ .messages(messages)
+ .build()
+ completion = client.chat().completions().create(followUpParams)
+ completion.choices()[0].message().content().orElse("No content")
+ } else {
+ message.content().orElse("No content")
+ }
+
+ println("\n$SEPARATOR")
+ println("Final Response:")
+ println(finalContent)
+ println(SEPARATOR)
+
+ completion.usage().ifPresent { usage ->
+ println("\nTotal Token Usage:")
+ println(" Input: ${usage.promptTokens()}")
+ println(" Output: ${usage.completionTokens()}")
+ println(" Total: ${usage.totalTokens()}")
+ }
+
+ span.setAttribute(AttributeKey.stringKey("gen_ai.completion"), finalContent)
+ span.setAttribute("response.content", finalContent)
+ span.setStatus(StatusCode.OK)
+ }
+ } catch (e: Exception) {
+ workflowSpan.recordException(e)
+ System.err.println("\n✗ Error during API call: ${e.message}")
+ e.printStackTrace()
+ workflowSpan.recordException(e)
+ workflowSpan.setStatus(StatusCode.ERROR)
+ } finally {
+ workflowSpan.end()
+ }
+
+ client.close()
+
+ println("\n$SEPARATOR")
+ println("Flushing traces to LangSmith...")
+ val flushed = OpenTelemetryConfig.flush(10, TimeUnit.SECONDS)
+
+ if (flushed) {
+ println("✓ Traces sent successfully!")
+ println("\nView your traces at:")
+ println(" https://smith.langchain.com/projects/$projectName")
+ } else {
+ System.err.println("✗ Warning: Flush may not have completed successfully")
+ }
+
+ println(SEPARATOR)
+ println("\nNote: Check the trace waterfall in LangSmith UI to see:")
+ println(" - Parent workflow span (chain)")
+ println(" - Child LLM spans (automatically created)")
+ println(" - Tool call spans (automatically created by wrapper)")
+}
+
+private fun executeTool(toolName: String, arguments: String): String {
+ return try {
+ val mapper = ObjectMapper()
+ val args = mapper.readTree(arguments)
+
+ if (toolName == "get_weather") {
+ val location = if (args.has("location")) args.get("location").asText() else "unknown"
+ val result = mapOf(
+ "location" to location,
+ "temperature" to "18°C",
+ "condition" to "Partly Cloudy",
+ "humidity" to "65%",
+ "wind" to "15 km/h"
+ )
+ mapper.writeValueAsString(result)
+ } else {
+ val errorMap = mapOf("error" to "Unknown tool: $toolName")
+ mapper.writeValueAsString(errorMap)
+ }
+ } catch (e: Exception) {
+ "{\"error\": \"${e.message}\"}"
+ }
+}
+
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/SpringBootLangSmithExample.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/SpringBootLangSmithExample.kt
new file mode 100644
index 00000000..631b5ba2
--- /dev/null
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/SpringBootLangSmithExample.kt
@@ -0,0 +1,47 @@
+package com.langchain.smith.example.otel
+
+import org.springframework.boot.SpringApplication
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import kotlin.system.exitProcess
+
+/**
+ * Spring Boot example: Send OpenTelemetry traces to LangSmith.
+ *
+ * Usage:
+ * export LANGSMITH_API_KEY=your_api_key
+ * export LANGSMITH_PROJECT=my-project # optional, defaults to "default"
+ * ./gradlew :langsmith-java-example:run -Pexample=SpringBootLangSmith
+ *
+ * Then make requests to:
+ * http://localhost:8080/api/chat
+ * http://localhost:8080/api/analyze?text=hello
+ */
+@SpringBootApplication
+class SpringBootLangSmithExample
+
+fun main(args: Array) {
+ println("=== Spring Boot + LangSmith OpenTelemetry Example ===\n")
+
+ val apiKey = System.getenv("LANGSMITH_API_KEY")
+ if (apiKey.isNullOrEmpty()) {
+ System.err.println("ERROR: LANGSMITH_API_KEY environment variable is required!")
+ System.err.println("\nUsage:")
+ System.err.println(" export LANGSMITH_API_KEY=your_api_key_here")
+ System.err.println(" export LANGSMITH_PROJECT=my-project # optional")
+ System.err.println(" ./gradlew :langsmith-java-example:run -Pexample=SpringBootLangSmith")
+ exitProcess(1)
+ }
+
+ val projectName = System.getenv("LANGSMITH_PROJECT") ?: "default"
+
+ println("Configuration:")
+ println(" Project: $projectName")
+ println(" Endpoint: https://api.smith.langchain.com/otel/v1/traces")
+ println("\nStarting Spring Boot application...")
+ println("Try these endpoints:")
+ println(" POST http://localhost:8080/api/chat")
+ println(" GET http://localhost:8080/api/analyze?text=hello")
+ println()
+
+ SpringApplication.run(SpringBootLangSmithExample::class.java, *args)
+}
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/config/OtelConfiguration.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/config/OtelConfiguration.kt
new file mode 100644
index 00000000..5878c5a0
--- /dev/null
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/config/OtelConfiguration.kt
@@ -0,0 +1,42 @@
+package com.langchain.smith.example.otel.config
+
+import com.langchain.smith.otel.OtelConfig
+import com.langchain.smith.otel.OtelTraceExporter
+import io.opentelemetry.api.trace.Tracer
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import java.time.Duration
+
+/**
+ * Spring configuration for OpenTelemetry integration with LangSmith.
+ */
+@Configuration
+class OtelConfiguration {
+
+ @Bean
+ fun otelTraceExporter(): OtelTraceExporter {
+ val apiKey = System.getenv("LANGSMITH_API_KEY")
+ var projectName = System.getenv("LANGSMITH_PROJECT")
+ if (projectName.isNullOrEmpty()) {
+ projectName = "default"
+ }
+
+ val headers = mapOf(
+ "x-api-key" to apiKey,
+ "Langsmith-Project" to projectName
+ )
+
+ val config = OtelConfig.builder()
+ .enabled(true)
+ .endpoint("https://api.smith.langchain.com/otel/v1/traces")
+ .headers(headers)
+ .timeout(Duration.ofSeconds(30))
+ .serviceName("spring-boot-langsmith")
+ .build()
+
+ return OtelTraceExporter.fromConfig(config)
+ }
+
+ @Bean
+ fun tracer(exporter: OtelTraceExporter): Tracer = exporter.tracer
+}
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/config/OtelShutdownHook.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/config/OtelShutdownHook.kt
new file mode 100644
index 00000000..eecfc488
--- /dev/null
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/config/OtelShutdownHook.kt
@@ -0,0 +1,27 @@
+package com.langchain.smith.example.otel.config
+
+import com.langchain.smith.otel.OtelTraceExporter
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.stereotype.Component
+import java.util.concurrent.TimeUnit
+import javax.annotation.PreDestroy
+
+/**
+ * Ensures OpenTelemetry traces are flushed on application shutdown.
+ */
+@Component
+class OtelShutdownHook @Autowired constructor(
+ private val exporter: OtelTraceExporter
+) {
+
+ @PreDestroy
+ fun onShutdown() {
+ println("\n→ Flushing OpenTelemetry traces...")
+ try {
+ exporter.flush().join(10000, TimeUnit.MILLISECONDS)
+ println("✓ Traces flushed successfully")
+ } catch (e: Exception) {
+ System.err.println("✗ Failed to flush traces: ${e.message}")
+ }
+ }
+}
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/controller/ChatController.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/controller/ChatController.kt
new file mode 100644
index 00000000..f806da23
--- /dev/null
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/controller/ChatController.kt
@@ -0,0 +1,94 @@
+package com.langchain.smith.example.otel.controller
+
+import com.langchain.smith.example.otel.service.LlmService
+import com.langchain.smith.otel.OtelSpanCreator
+import io.opentelemetry.api.trace.Span
+import io.opentelemetry.api.trace.StatusCode
+import io.opentelemetry.api.trace.Tracer
+import io.opentelemetry.context.Scope
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.RestController
+
+/**
+ * REST controller demonstrating OpenTelemetry tracing with LangSmith.
+ */
+@RestController
+@RequestMapping("/api")
+class ChatController @Autowired constructor(
+ private val tracer: Tracer,
+ private val llmService: LlmService
+) {
+
+ @PostMapping("/chat")
+ fun chat(@RequestBody request: Map): Map {
+ val userMessage = request["message"] ?: "Hello!"
+
+ val rootSpan = OtelSpanCreator.createChainSpan(
+ tracer, "chat_request", "spring-boot-langsmith", null
+ )
+
+ try {
+ rootSpan.makeCurrent().use {
+ OtelSpanCreator.setInput(rootSpan, userMessage)
+ println("→ Processing chat request: $userMessage")
+ val response = llmService.generateResponse(userMessage)
+ OtelSpanCreator.setOutput(rootSpan, response)
+ rootSpan.setStatus(StatusCode.OK)
+ println("← Chat response generated")
+ return mapOf(
+ "request" to userMessage,
+ "response" to response,
+ "model" to "gpt-4",
+ "trace_id" to rootSpan.spanContext.traceId
+ )
+ }
+ } catch (e: Exception) {
+ rootSpan.setStatus(StatusCode.ERROR, e.message)
+ throw e
+ } finally {
+ rootSpan.end()
+ }
+ }
+
+ @GetMapping("/analyze")
+ fun analyze(@RequestParam text: String): Map {
+ val analysisSpan = OtelSpanCreator.createChainSpan(
+ tracer, "text_analysis", "spring-boot-langsmith", null
+ )
+
+ try {
+ analysisSpan.makeCurrent().use {
+ OtelSpanCreator.setInput(analysisSpan, text)
+ println("→ Analyzing text: $text")
+ val wordCount = text.split("\\s+".toRegex()).size
+ val sentiment = llmService.analyzeSentiment(text)
+ val result = "Word count: $wordCount, Sentiment: $sentiment"
+ OtelSpanCreator.setOutput(analysisSpan, result)
+ analysisSpan.setStatus(StatusCode.OK)
+ println("← Analysis complete")
+ return mapOf(
+ "text" to text,
+ "word_count" to wordCount,
+ "sentiment" to sentiment,
+ "trace_id" to analysisSpan.spanContext.traceId
+ )
+ }
+ } catch (e: Exception) {
+ analysisSpan.setStatus(StatusCode.ERROR, e.message)
+ throw e
+ } finally {
+ analysisSpan.end()
+ }
+ }
+
+ @GetMapping("/health")
+ fun health(): Map = mapOf(
+ "status" to "healthy",
+ "service" to "spring-boot-langsmith"
+ )
+}
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/service/LlmService.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/service/LlmService.kt
new file mode 100644
index 00000000..e4e56f2a
--- /dev/null
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/otel/service/LlmService.kt
@@ -0,0 +1,78 @@
+package com.langchain.smith.example.otel.service
+
+import com.langchain.smith.otel.OtelSpanCreator
+import io.opentelemetry.api.trace.Span
+import io.opentelemetry.api.trace.StatusCode
+import io.opentelemetry.api.trace.Tracer
+import io.opentelemetry.context.Scope
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.stereotype.Service
+
+/**
+ * Service layer demonstrating nested OpenTelemetry spans.
+ */
+@Service
+class LlmService @Autowired constructor(
+ private val tracer: Tracer
+) {
+
+ /**
+ * Simulates an LLM API call with tracing.
+ */
+ fun generateResponse(input: String): String {
+ val llmSpan = OtelSpanCreator.createLlmSpan(
+ tracer, "openai.chat", "openai", "gpt-4", "spring-boot-langsmith", null
+ )
+
+ try {
+ llmSpan.makeCurrent().use {
+ OtelSpanCreator.setInput(llmSpan, input)
+ println(" → Calling OpenAI API...")
+ Thread.sleep(500)
+ val response = "I received your message: '$input'. How can I help you today?"
+ OtelSpanCreator.setOutput(llmSpan, response)
+ OtelSpanCreator.setTokenUsage(llmSpan, 15, 20)
+ llmSpan.setStatus(StatusCode.OK)
+ println(" ← OpenAI API response received")
+ return response
+ }
+ } catch (e: Exception) {
+ llmSpan.setStatus(StatusCode.ERROR, e.message)
+ throw RuntimeException("LLM call failed", e)
+ } finally {
+ llmSpan.end()
+ }
+ }
+
+ /**
+ * Simulates sentiment analysis with tracing.
+ */
+ fun analyzeSentiment(text: String): String {
+ val sentimentSpan = OtelSpanCreator.createLlmSpan(
+ tracer, "sentiment_analysis", "openai", "gpt-4", "spring-boot-langsmith", null
+ )
+
+ try {
+ sentimentSpan.makeCurrent().use {
+ OtelSpanCreator.setInput(sentimentSpan, text)
+ println(" → Analyzing sentiment...")
+ Thread.sleep(300)
+ val sentiment = when {
+ text.lowercase().contains("good") || text.lowercase().contains("great") -> "positive"
+ text.lowercase().contains("bad") || text.lowercase().contains("terrible") -> "negative"
+ else -> "neutral"
+ }
+ OtelSpanCreator.setOutput(sentimentSpan, sentiment)
+ OtelSpanCreator.setTokenUsage(sentimentSpan, 8, 2)
+ sentimentSpan.setStatus(StatusCode.OK)
+ println(" ← Sentiment: $sentiment")
+ return sentiment
+ }
+ } catch (e: Exception) {
+ sentimentSpan.setStatus(StatusCode.ERROR, e.message)
+ throw RuntimeException("Sentiment analysis failed", e)
+ } finally {
+ sentimentSpan.end()
+ }
+ }
+}
diff --git a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/ExperimentUtils.kt b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/util/ExperimentUtils.kt
similarity index 98%
rename from langsmith-java-example/src/main/kotlin/com/langchain/smith/example/ExperimentUtils.kt
rename to langsmith-java-example/src/main/kotlin/com/langchain/smith/example/util/ExperimentUtils.kt
index 37db12e0..23cd712a 100644
--- a/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/ExperimentUtils.kt
+++ b/langsmith-java-example/src/main/kotlin/com/langchain/smith/example/util/ExperimentUtils.kt
@@ -1,4 +1,4 @@
-package com.langchain.smith.example
+package com.langchain.smith.example.util
import com.langchain.smith.models.datasets.Dataset
import java.nio.charset.StandardCharsets