diff --git a/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelConfig.java b/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelConfig.java deleted file mode 100644 index 6498709b..00000000 --- a/langsmith-java-core/src/main/java/com/langchain/smith/otel/OtelConfig.java +++ /dev/null @@ -1,230 +0,0 @@ -package com.langchain.smith.otel; - -import java.time.Duration; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -/** - * 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: - *

- * - *

System properties: - *

- */ -public final class OtelConfig { - private final String endpoint; - private final boolean enabled; - private final Duration timeout; - private final Map headers; - private final String serviceName; - - private OtelConfig(Builder builder) { - this.endpoint = builder.endpoint; - this.enabled = builder.enabled; - this.timeout = builder.timeout; - this.headers = Collections.unmodifiableMap(new HashMap<>(builder.headers)); - this.serviceName = builder.serviceName; - } - - /** - * Returns the OpenTelemetry endpoint URL. - * - * @return the endpoint URL - */ - public String getEndpoint() { - return endpoint; - } - - /** - * Returns whether OpenTelemetry export is enabled. - * - * @return true if enabled, false otherwise - */ - public boolean isEnabled() { - return enabled; - } - - /** - * Returns the timeout for export requests. - * - * @return the timeout duration - */ - public Duration getTimeout() { - return timeout; - } - - /** - * Returns additional headers to include in export requests. - * - * @return a map of header names to values - */ - public Map 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