From 24680beb2f50ab99332fb8fcd55ca6e50890e8d1 Mon Sep 17 00:00:00 2001 From: xornivore <30147836+xornivore@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:13:27 -0500 Subject: [PATCH] chore: Move LangSmith OTel Java wrappers PoC into SDK --- langsmith-java-wrappers/build.gradle.kts | 60 ++ .../smith/wrappers/openai/OpenAIWrappers.java | 54 ++ .../wrappers/openai/OpenTelemetryConfig.java | 418 +++++++++ .../smith/wrappers/openai/TracingUtils.java | 219 +++++ .../wrappers/openai/WrappedChatService.java | 841 ++++++++++++++++++ .../wrappers/openai/WrappedOpenAIClient.java | 277 ++++++ .../openai/WrappedResponseService.java | 414 +++++++++ .../openai/examples/ManualSpansExample.java | 446 ++++++++++ .../openai/examples/WithLangSmithExample.java | 315 +++++++ .../smith/wrappers/package-info.java | 7 + .../openai/WrappedOpenAIClientTest.java | 67 ++ .../smith/wrappers/package-info.java | 5 + 12 files changed, 3123 insertions(+) create mode 100644 langsmith-java-wrappers/build.gradle.kts create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/OpenAIWrappers.java create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.java create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/TracingUtils.java create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedChatService.java create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.java create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedResponseService.java create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/examples/ManualSpansExample.java create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/examples/WithLangSmithExample.java create mode 100644 langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/package-info.java create mode 100644 langsmith-java-wrappers/src/test/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClientTest.java create mode 100644 langsmith-java-wrappers/src/test/java/com/langchain/smith/wrappers/package-info.java diff --git a/langsmith-java-wrappers/build.gradle.kts b/langsmith-java-wrappers/build.gradle.kts new file mode 100644 index 00000000..25e7319b --- /dev/null +++ b/langsmith-java-wrappers/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + id("langchain.java") + id("langchain.publish") +} + +// Suppress obsolete Java 8 warnings since we're using Java 21 toolchain but targeting Java 8 +tasks.withType().configureEach { + options.compilerArgs.add("-Xlint:-options") +} + +// Customize POM for wrappers module +configure { + pom { + name.set("LangSmith Java Wrappers") + description.set("OpenTelemetry integration wrappers for LangSmith in Java. " + + "This package provides OpenTelemetry wrappers for AI model clients to enable " + + "automatic tracing and monitoring with LangSmith.") + } +} + +dependencies { + // OpenAI Java SDK - Required for examples to run + // The wrappers wrap the OpenAI SDK generated by Stainless + // Users should add this dependency when using the wrappers in their own projects + // Example: implementation("com.openai:openai-java:4.6.1") + implementation("com.openai:openai-java:4.6.1") + + // OpenTelemetry API + api("io.opentelemetry:opentelemetry-api:1.32.0") + api("io.opentelemetry:opentelemetry-context:1.32.0") + + // OpenTelemetry SDK (for configuring exporters) + implementation("io.opentelemetry:opentelemetry-sdk:1.32.0") + implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.32.0") + // Note: OtlpHttpSpanExporter should be in opentelemetry-exporter-otlp, but if not found, + // we may need to check the actual package structure + + // Test dependencies + testImplementation("com.openai:openai-java:4.6.1") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.3") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +// Task to run examples +tasks.register("runExample") { + group = "application" + description = "Run an example class" + + classpath = sourceSets["main"].runtimeClasspath + + // Get the example class from project property, or use default + val exampleClass = project.findProperty("exampleClass") as String? + mainClass.set(exampleClass ?: "com.langchain.smith.wrappers.openai.examples.SimpleChatCompletionExample") + + // Pass through environment variables (especially OPENAI_API_KEY, LANGSMITH_API_KEY, etc.) + environment.putAll(System.getenv()) +} + diff --git a/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/OpenAIWrappers.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/OpenAIWrappers.java new file mode 100644 index 00000000..4efc53ae --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/OpenAIWrappers.java @@ -0,0 +1,54 @@ +package com.langchain.smith.wrappers.openai; + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; + +/** + * Utility class for wrapping OpenAI clients with LangSmith tracing + * capabilities. + * + *

+ * This class provides a simple way to wrap OpenAI clients similar to the Python + * langsmith-sdk wrapper functionality. + */ +public final class OpenAIWrappers { + + private OpenAIWrappers() { + // Utility class - prevent instantiation + } + + /** + * Wraps an OpenAI client to add LangSmith tracing capabilities. + * + *

+ * This is a no-op wrapper that maintains the same developer experience as the + * original client. All configuration options and methods work exactly as they + * would with the original client. + * + * @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) { + if (client == null) { + throw new IllegalArgumentException("Client cannot be null"); + } + return new WrappedOpenAIClient(client); + } + + /** + * Creates a wrapped OpenAI client from environment variables. + * + *

+ * This is equivalent to: + * + *

{@code
+   * wrap(OpenAIOkHttpClient.fromEnv())
+   * }
+ * + * @return a wrapped OpenAI client configured from environment variables + */ + public static WrappedOpenAIClient wrapFromEnv() { + return wrap(OpenAIOkHttpClient.fromEnv()); + } +} diff --git a/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.java new file mode 100644 index 00000000..7e6d50b1 --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/OpenTelemetryConfig.java @@ -0,0 +1,418 @@ +package com.langchain.smith.wrappers.openai; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.opentelemetry.sdk.trace.SpanProcessor; +import java.util.concurrent.TimeUnit; + +/** + * 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. You can use this programmatically, or configure via environment + * variables. + * + *

+ * Example usage: + * + *

{@code
+ * // Configure OpenTelemetry for LangSmith before using the wrapper
+ * OpenTelemetryConfig.configureForLangSmith(
+ *     "your-langsmith-api-key",
+ *     "your-project-name");
+ *
+ * // Now use the wrapped client - traces will be sent to LangSmith
+ * WrappedOpenAIClient client = OpenAIWrappers.wrapFromEnv();
+ * }
+ */ +public final class OpenTelemetryConfig { + + private OpenTelemetryConfig() { + // Utility class + } + + /** + * LangSmith OTLP endpoint for traces. + * Can be overridden via LANGSMITH_OTLP_ENDPOINT environment variable or + * by passing a custom endpoint to the configuration methods. + */ + public static final String LANGSMITH_OTLP_ENDPOINT = "https://api.smith.langchain.com/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 + } + + /** + * Configures OpenTelemetry to export traces to LangSmith. + * + *

+ * This method configures the global OpenTelemetry instance to send traces to + * LangSmith's + * OTLP endpoint. After calling this method, all spans created by the wrapped + * OpenAI client + * will be exported to LangSmith. + * + * @param apiKey your LangSmith API key + * @param projectName your LangSmith project name (optional, can be null) + * @return the configured OpenTelemetry instance + */ + public static OpenTelemetry configureForLangSmith(String apiKey, String projectName) { + return configureForLangSmith(apiKey, projectName, null, null); + } + + /** + * Configures OpenTelemetry to export traces to LangSmith with a custom service + * name. + * + * @param apiKey your LangSmith API key + * @param projectName your LangSmith project name (optional, can be null) + * @param serviceName the service name to identify your application (defaults to + * "langsmith-java-otel-wrappers") + * @return the configured OpenTelemetry instance + */ + public static OpenTelemetry configureForLangSmith(String apiKey, String projectName, + String serviceName) { + return configureForLangSmith(apiKey, projectName, serviceName, null); + } + + /** + * Configures OpenTelemetry to export traces to LangSmith with a custom service + * name and endpoint. + * + * @param apiKey your LangSmith API key + * @param projectName your LangSmith project name (optional, can be null) + * @param serviceName the service name to identify your application (defaults to + * "langsmith-java-otel-wrappers") + * @param endpoint the OTLP endpoint URL (optional, defaults to + * LANGSMITH_OTLP_ENDPOINT constant) + * @return the configured OpenTelemetry instance + */ + public static OpenTelemetry configureForLangSmith(String apiKey, String projectName, + String serviceName, String endpoint) { + return configureForLangSmith(apiKey, projectName, serviceName, endpoint, + SpanProcessorType.BATCH, 512); + } + + /** + * Configures OpenTelemetry to export traces to LangSmith with custom batch + * size. + * + * @param apiKey your LangSmith API key + * @param projectName your LangSmith project name (optional, can be null) + * @param serviceName the service name to identify your application (defaults + * to + * "langsmith-java-otel-wrappers") + * @param endpoint the OTLP endpoint URL (optional, defaults to + * LANGSMITH_OTLP_ENDPOINT constant) + * @param maxBatchSize the maximum batch size before export is triggered + * (set to 1 for immediate export, default 512) + * @return the configured OpenTelemetry instance + */ + public static OpenTelemetry configureForLangSmith(String apiKey, String projectName, + String serviceName, String endpoint, int maxBatchSize) { + return configureForLangSmith(apiKey, projectName, serviceName, endpoint, + SpanProcessorType.BATCH, maxBatchSize); + } + + /** + * Configures OpenTelemetry to export traces to LangSmith with custom span + * processor type and batch size. + * + * @param apiKey your LangSmith API key + * @param projectName your LangSmith project name (optional, can be null) + * @param serviceName the service name to identify your application + * (defaults to "langsmith-java-otel-wrappers") + * @param endpoint the OTLP endpoint URL (optional, defaults to + * LANGSMITH_OTLP_ENDPOINT constant) + * @param processorType the span processor type (BATCH or SIMPLE) + * @param maxBatchSize the maximum batch size before export is triggered + * (only used for BATCH processor, set to 1 for + * immediate export) + * @return the configured OpenTelemetry instance + */ + public static OpenTelemetry configureForLangSmith(String apiKey, String projectName, + String serviceName, String endpoint, SpanProcessorType processorType, int maxBatchSize) { + if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalArgumentException("LangSmith API key cannot be null or empty"); + } + + // Use provided endpoint or default to LANGSMITH_OTLP_ENDPOINT + String endpointUrl = endpoint != null && !endpoint.isEmpty() + ? endpoint + : LANGSMITH_OTLP_ENDPOINT; + + // Create OTLP HTTP exporter configured for LangSmith + // Build the exporter with conditional headers + // Note: Using Object to work around Java 8 limitation (can't use var) + // The builder() method returns a builder that supports method chaining + Object builder = 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()) { + // Use reflection to call addHeader on the builder + try { + java.lang.reflect.Method addHeaderMethod = builder.getClass().getMethod("addHeader", String.class, + String.class); + builder = addHeaderMethod.invoke(builder, "langsmith-project", projectName); + } catch (Exception e) { + throw new RuntimeException("Failed to add project header", e); + } + } + + // Build the exporter using reflection + OtlpHttpSpanExporter spanExporter; + try { + java.lang.reflect.Method buildMethod = builder.getClass().getMethod("build"); + spanExporter = (OtlpHttpSpanExporter) buildMethod.invoke(builder); + } catch (Exception e) { + throw new RuntimeException("Failed to build OtlpHttpSpanExporter", e); + } + + // 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(); + + OpenTelemetry openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .buildAndRegisterGlobal(); + + return openTelemetry; + } + + /** + * Configures OpenTelemetry from environment variables. + * + *

+ * Reads configuration from the following environment variables: + *

    + *
  • LANGSMITH_API_KEY - Required: Your LangSmith API key
  • + *
  • LANGSMITH_PROJECT - Optional: Your LangSmith project name
  • + *
  • OTEL_SERVICE_NAME - Optional: Service name (defaults to + * "langsmith-java-otel-wrappers")
  • + *
  • LANGSMITH_OTLP_ENDPOINT - Optional: Custom OTLP endpoint URL (defaults to + * LANGSMITH_OTLP_ENDPOINT constant)
  • + *
+ * + * @return the configured OpenTelemetry instance + * @throws IllegalStateException if LANGSMITH_API_KEY is not set + */ + public static OpenTelemetry configureFromEnv() { + String apiKey = System.getenv("LANGSMITH_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalStateException( + "LANGSMITH_API_KEY environment variable is required. " + + "Please set it with your LangSmith API key."); + } + + String projectName = System.getenv("LANGSMITH_PROJECT"); + String serviceName = System.getenv("OTEL_SERVICE_NAME"); + String endpoint = System.getenv("LANGSMITH_OTLP_ENDPOINT"); + + return configureForLangSmith(apiKey, projectName, serviceName, endpoint); + } + + /** + * 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 + @SuppressWarnings("resource") + 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()) { + System.err.println("Warning: Flush did not complete successfully"); + } + return result.isSuccess(); + } catch (Exception e) { + System.err.println("Warning: Failed to flush spans: " + e.getMessage()); + e.printStackTrace(); + return false; + } + } + } + return true; + } + + /** + * 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) { + System.err.println("Warning: Failed to shutdown OpenTelemetry: " + e.getMessage()); + e.printStackTrace(); + } + } + } + } + + /** + * 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) { + System.out.println("[LangSmith] Exporting " + spans.size() + " span(s):"); + for (io.opentelemetry.sdk.trace.data.SpanData span : spans) { + System.out.println(" - " + 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()) { + System.err.println("[LangSmith ERROR] Failed to export " + spans.size() + " span(s) to LangSmith"); + System.err.println(" This usually indicates a network error or authentication problem"); + System.err.println(" Check your LANGSMITH_API_KEY and network connectivity"); + } else { + System.out.println("[LangSmith] Successfully exported " + spans.size() + " span(s)"); + } + } catch (Exception e) { + System.err.println("[LangSmith ERROR] Exception waiting for export result: " + e.getMessage()); + e.printStackTrace(); + } + } else { + // Without DEBUG, still log errors but don't block + result.whenComplete(() -> { + if (!result.isSuccess()) { + System.err.println("[LangSmith ERROR] Failed to export " + spans.size() + " span(s) to LangSmith"); + System.err.println(" This usually indicates a network error or authentication problem"); + System.err.println(" Check your LANGSMITH_API_KEY and network connectivity"); + } + }); + } + return result; + } + + @Override + public io.opentelemetry.sdk.common.CompletableResultCode flush() { + if (DEBUG) { + System.out.println("[LangSmith] Flushing spans..."); + } + io.opentelemetry.sdk.common.CompletableResultCode result = delegate.flush(); + result.whenComplete(() -> { + if (!result.isSuccess()) { + System.err.println("[LangSmith ERROR] Failed to flush spans"); + } else if (DEBUG) { + System.out.println("[LangSmith] Flush completed successfully"); + } + }); + return result; + } + + @Override + public io.opentelemetry.sdk.common.CompletableResultCode shutdown() { + if (DEBUG) { + System.out.println("[LangSmith] Shutting down span exporter..."); + } + return delegate.shutdown(); + } + } +} diff --git a/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/TracingUtils.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/TracingUtils.java new file mode 100644 index 00000000..43ca9a7b --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/TracingUtils.java @@ -0,0 +1,219 @@ +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; + +/** + * Utility class for creating and managing OpenTelemetry spans for OpenAI API + * calls. + * + *

+ * This class follows the LangSmith OTEL conventions documented in + * LANGSMITH_OTEL.md. + */ +final class TracingUtils { + + private static final String INSTRUMENTATION_NAME = "langsmith-java-otel-wrappers"; + + private TracingUtils() { + // Utility class + } + + /** + * Gets or creates a tracer for OpenAI operations. + * + * @return a tracer instance + */ + static Tracer getTracer() { + // Try to get the global tracer if available + try { + Tracer tracer = io.opentelemetry.api.GlobalOpenTelemetry.get() + .getTracer(INSTRUMENTATION_NAME); + + // Debug: Check if tracer is a noop tracer + boolean debug = Boolean.getBoolean("langsmith.debug") + || "true".equalsIgnoreCase(System.getenv("LANGSMITH_DEBUG")); + if (debug) { + // Check if the OpenTelemetry instance is a noop + 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) { + // Fall back to noop - GlobalOpenTelemetry.get() will return a noop + // implementation + // if OpenTelemetry is not configured + return io.opentelemetry.api.GlobalOpenTelemetry.get() + .getTracer(INSTRUMENTATION_NAME); + } + } + + /** + * Creates a span builder for an OpenAI operation with LangSmith-specific + * attributes. + * + * @param model the model name (e.g., "gpt-4o-mini") + * @param operationType the operation type (e.g., "chat", "response") + * @param spanKind the LangSmith span kind (e.g., "llm") - can be null + * @return a span builder with CLIENT kind and core attributes + */ + 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"); + + // Set LangSmith span kind on the builder (important for LangSmith detection) + if (spanKind != null) { + builder.setAttribute("langsmith.span.kind", spanKind); + } + + return builder; + } + + /** + * Creates a span builder for an OpenAI operation (defaults to "llm" span kind). + * + * @param model the model name (e.g., "gpt-4o-mini") + * @param operationType the operation type (e.g., "chat", "response") + * @return a span builder with CLIENT kind and core attributes + */ + static SpanBuilder createSpanBuilder(String model, String operationType) { + return createSpanBuilder(model, operationType, "llm"); + } + + /** + * Sets common span attributes for OpenAI LLM requests. + * + * Note: Core attributes (gen_ai.system, gen_ai.operation.name, + * gen_ai.provider.name, + * langsmith.span.kind) are already set on the SpanBuilder. This method sets + * additional + * request-specific attributes. + * + * @param span the span to set attributes on + * @param model the model name + */ + static void setRequestAttributes(Span span, String model) { + if (model != null) { + span.setAttribute("gen_ai.request.model", model); + } + } + + /** + * Sets request parameter attributes on a span. + * + * @param span the span to set attributes on + * @param temperature the temperature parameter (nullable) + * @param topP the top_p parameter (nullable) + * @param maxTokens the max_tokens parameter (nullable) + */ + 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); + } + } + + /** + * Sets input messages as JSON array string. + * + * @param span the span to set attributes on + * @param messagesJson the messages in JSON array format + */ + static void setInputMessages(Span span, String messagesJson) { + if (messagesJson != null) { + span.setAttribute("gen_ai.input.messages", messagesJson); + } + } + + /** + * Sets output messages as JSON array string. + * + * @param span the span to set attributes on + * @param messagesJson the messages in JSON array format + */ + static void setOutputMessages(Span span, String messagesJson) { + if (messagesJson != null) { + span.setAttribute("gen_ai.output.messages", messagesJson); + } + } + + /** + * Sets response attributes on a span. + * + * @param span the span to set attributes on + * @param inputTokens number of input tokens + * @param outputTokens number of output tokens + * @param totalTokens total tokens used + */ + 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); + } + } + + /** + * Sets response model and finish reason attributes. + * + * @param span the span to set attributes on + * @param responseModel the model used in the response + * @param finishReason the finish reason + */ + 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); + } + } + + /** + * Records an exception on a span and marks it as an error. + * + * @param span the span to record the exception on + * @param exception the exception that occurred + */ + static void recordException(Span span, Throwable exception) { + span.recordException(exception); + span.setAttribute("error", true); + } + + /** + * Escapes a JSON string by replacing special characters. + * + * @param str the string to escape + * @return the escaped string + */ + 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-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedChatService.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedChatService.java new file mode 100644 index 00000000..e3bc2929 --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedChatService.java @@ -0,0 +1,841 @@ +package com.langchain.smith.wrappers.openai; + +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.chat.completions.ChatCompletionMessageToolCall; +import com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall; +import com.openai.models.chat.completions.StructuredChatCompletion; +import com.openai.models.chat.completions.StructuredChatCompletionCreateParams; +import com.openai.core.RequestOptions; +import com.openai.core.http.StreamResponse; +import com.openai.models.chat.completions.ChatCompletionChunk; +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 com.openai.core.ClientOptions; + +/** + * 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 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() : "unknown"; + + Span span = TracingUtils.createSpanBuilder(model, "chat") + .startSpan(); + + // Debug: Check if span is recording (not a noop span) + boolean debug = Boolean.getBoolean("langsmith.debug") + || "true".equalsIgnoreCase(System.getenv("LANGSMITH_DEBUG")); + if (debug) { + boolean isRecording = span.isRecording(); + System.out.println("[WrappedChatService] Created span: " + span.getSpanContext().getSpanId() + + ", isRecording: " + isRecording + ", traceId: " + span.getSpanContext().getTraceId()); + } + + try (Scope scope = span.makeCurrent()) { + // 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); + + 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() != null ? result.model().toString() : null; + String finishReason = !result.choices().isEmpty() && result.choices().get(0).finishReason() != null + ? 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 usage information from result + result.usage().ifPresent(usage -> { + TracingUtils.setResponseAttributes(span, + (long) usage.promptTokens(), + (long) usage.completionTokens(), + (long) usage.totalTokens()); + }); + + // Create tool call spans for any tool calls in the response + createToolCallSpans(result, span); + + return result; + } catch (Exception e) { + TracingUtils.recordException(span, e); + throw e; + } finally { + if (debug) { + System.out.println("[WrappedChatService] Ending span: " + span.getSpanContext().getSpanId()); + } + span.end(); + if (debug) { + System.out.println("[WrappedChatService] Span ended: " + span.getSpanContext().getSpanId()); + } + } + } + + /** + * 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() == null || params.messages().isEmpty()) { + return "[]"; + } + + boolean debug = Boolean.getBoolean("langsmith.debug") + || "true".equalsIgnoreCase(System.getenv("LANGSMITH_DEBUG")); + if (debug) { + System.out.println("[formatInputMessages] Processing " + params.messages().size() + " message(s)"); + } + + 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 (debug variable already declared above) + if (debug) { + System.out.println("[formatInputMessages] Processing message: " + fullClassName); + java.lang.reflect.Method[] allMethods = messageParam.getClass().getMethods(); + System.out.println("[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 (debug) { + System.out.println("[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 (debug) { + System.out.println("[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 (debug) { + System.out + .println("[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 (debug) { + System.out.println("[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 (debug) { + System.out.println("[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 (debug) { + System.out.println("[formatInputMessages] No content() method on actual message"); + } + } + } + } catch (Exception e) { + if (debug) { + System.out.println("[formatInputMessages] Error accessing message: " + e.getMessage()); + e.printStackTrace(); + } + } + + // 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 (debug) { + System.out.println("[formatInputMessages] Error calling role(): " + e.getMessage()); + } + // 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 (debug) { + System.out.println("[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 (debug) { + System.out.println("[formatInputMessages] Final: role=" + role + ", content=" + + (content != null ? content.substring(0, Math.min(50, content.length())) : "null")); + } + + json.append("}"); + } + json.append("]"); + + String result = json.toString(); + if (debug) { + System.out.println("[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; + } + + boolean debug = Boolean.getBoolean("langsmith.debug") + || "true".equalsIgnoreCase(System.getenv("LANGSMITH_DEBUG")); + + 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, debug); + } + // 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) + * @param debug whether debug logging is enabled + */ + private void createToolCallSpan(ChatCompletionMessageFunctionToolCall functionToolCall, Span parentSpan, + boolean debug) { + 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); + } + + if (debug) { + System.out + .println("[WrappedChatService] Created tool call span: " + toolCallSpan.getSpanContext().getSpanId() + + ", tool=" + toolName + ", arguments=" + toolArguments + ", parent=" + + 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 (debug) { + System.out.println("[WrappedChatService] Error creating tool call span: " + e.getMessage()); + e.printStackTrace(); + } + } + } + + /** + * 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("\""); + }); + + json.append("}"); + } + json.append("]"); + + return json.toString(); + } + + @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() + : "unknown"; + + Span span = TracingUtils.createSpanBuilder(model, "chat") + .startSpan(); + + try (Scope scope = span.makeCurrent()) { + // 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() : "unknown"; + + Span span = TracingUtils.createSpanBuilder(model, "chat") + .startSpan(); + + try (Scope scope = span.makeCurrent()) { + // 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-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.java new file mode 100644 index 00000000..58108be2 --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClient.java @@ -0,0 +1,277 @@ +package com.langchain.smith.wrappers.openai; + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import java.util.function.Consumer; +import com.openai.core.ClientOptions; + +/** + * 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(); + } + + /** + * 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-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedResponseService.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedResponseService.java new file mode 100644 index 00000000..2487e90a --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/WrappedResponseService.java @@ -0,0 +1,414 @@ +package com.langchain.smith.wrappers.openai; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.StructuredResponse; +import com.openai.models.responses.StructuredResponseCreateParams; +import com.openai.core.RequestOptions; +import com.openai.core.http.StreamResponse; +import com.openai.models.responses.ResponseStreamEvent; +import com.openai.services.blocking.ResponseService; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; +import java.util.function.Consumer; +import com.openai.core.ClientOptions; + +/** + * 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() != null + ? params.model().toString() + : "unknown"; + + Span span = TracingUtils.createSpanBuilder(model, "response") + .startSpan(); + + try (Scope scope = span.makeCurrent()) { + // 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() + : "unknown"; + + Span span = TracingUtils.createSpanBuilder(model, "response") + .startSpan(); + + try (Scope scope = span.makeCurrent()) { + // 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() + : "unknown"; + + Span span = TracingUtils.createSpanBuilder(model, "response") + .startSpan(); + + try (Scope scope = span.makeCurrent()) { + // 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() + : "unknown"; + + Span span = TracingUtils.createSpanBuilder(model, "response") + .startSpan(); + + try (Scope scope = span.makeCurrent()) { + // 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(); + } + } + + // 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-wrappers/src/main/java/com/langchain/smith/wrappers/openai/examples/ManualSpansExample.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/examples/ManualSpansExample.java new file mode 100644 index 00000000..712d5e5c --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/examples/ManualSpansExample.java @@ -0,0 +1,446 @@ +package com.langchain.smith.wrappers.openai.examples; + +import com.langchain.smith.wrappers.openai.OpenTelemetryConfig; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.ChatModel; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; + +/** + * Example demonstrating manual OpenTelemetry span creation for LangSmith + * tracing. + * + *

+ * This example shows how to manually create spans with proper gen_ai attributes + * that will be correctly interpreted by LangSmith. It demonstrates: + *

    + *
  • Single span LLM invocation wrapped in a manually created span
  • + *
  • Nested spans with parent-child relationships (root with two + * children)
  • + *
+ * + *

+ * The spans created here follow the LangSmith OTEL conventions documented in + * LANGSMITH_OTEL.md, ensuring proper mapping to LangSmith runs. + */ +public class ManualSpansExample { + + private static final String INSTRUMENTATION_NAME = "langsmith-java-otel-wrappers"; + + /** + * Main method demonstrating manual span creation. + * + *

+ * Prerequisites: + *

    + *
  1. Set OPENAI_API_KEY environment variable
  2. + *
  3. Set LANGSMITH_API_KEY environment variable (your LangSmith API key)
  4. + *
  5. Optionally set LANGSMITH_PROJECT environment variable
  6. + *
+ * + * @param args command line arguments (not used) + */ + public static void main(String[] args) { + // Configure OpenTelemetry for LangSmith with batch size 1 for immediate export + // This eliminates the need for sleep delays before flush + System.out.println("Configuring OpenTelemetry for LangSmith..."); + try { + String apiKey = System.getenv("LANGSMITH_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalStateException("LANGSMITH_API_KEY environment variable is required"); + } + String projectName = System.getenv("LANGSMITH_PROJECT"); + String serviceName = System.getenv("OTEL_SERVICE_NAME"); + String endpoint = System.getenv("LANGSMITH_OTLP_ENDPOINT"); + + // Option 1: Use SimpleSpanProcessor for synchronous, immediate export + // This ensures spans are sent immediately without buffering + // SimpleSpanProcessor blocks on span.end() until export completes + OpenTelemetryConfig.configureForLangSmith(apiKey, projectName, serviceName, endpoint, + OpenTelemetryConfig.SpanProcessorType.SIMPLE, 1); + System.out.println("✓ OpenTelemetry configured with SimpleSpanProcessor (immediate export)\n"); + + // Option 2: Use BatchSpanProcessor with batch size = 1 for non-blocking + // immediate export + // Uncomment this line and comment Option 1 above to test BatchSpanProcessor + // This is non-blocking but still exports immediately due to batch size = 1 + // OpenTelemetryConfig.configureForLangSmith(apiKey, projectName, serviceName, + // endpoint, + // OpenTelemetryConfig.SpanProcessorType.BATCH, 1); + // System.out.println("✓ OpenTelemetry configured with BatchSpanProcessor + // (batch size = 1)\n"); + } catch (IllegalStateException e) { + System.err.println("✗ Error configuring OpenTelemetry: " + e.getMessage()); + System.err.println("\nPlease set the following environment variables:"); + System.err.println(" - LANGSMITH_API_KEY (required)"); + System.err.println(" - LANGSMITH_PROJECT (optional)"); + return; + } + + // Get OpenAI API key + String openaiApiKey = System.getenv("OPENAI_API_KEY"); + if (openaiApiKey == null || openaiApiKey.isEmpty()) { + System.err.println("✗ OPENAI_API_KEY environment variable is required"); + return; + } + + // Create OpenAI client (not wrapped - we'll create spans manually) + OpenAIClient client = OpenAIOkHttpClient.builder() + .apiKey(openaiApiKey) + .build(); + + try { + // Example 1: Single span LLM invocation + System.out.println(repeatString("=", 60)); + System.out.println("Example 1: Single Span LLM Invocation"); + System.out.println(repeatString("=", 60)); + exampleSingleSpan(client); + + // Example 2: Nested spans (root with two children) + System.out.println("\n" + repeatString("=", 60)); + System.out.println("Example 2: Nested Spans (Root with Two Children)"); + System.out.println(repeatString("=", 60)); + exampleNestedSpans(client); + + } catch (Exception e) { + System.err.println("✗ Error during execution: " + e.getMessage()); + e.printStackTrace(); + } finally { + // Close the client + client.close(); + + System.out.println("\n" + repeatString("=", 60)); + boolean flushed = OpenTelemetryConfig.flush(10, java.util.concurrent.TimeUnit.SECONDS); + if (!flushed) { + System.err.println("✗ Warning: Flush did not complete successfully"); + System.err.println(" Some spans may not have been exported to LangSmith"); + } else { + System.out.println("✓ Spans flushed successfully"); + } + + System.out.println("\n✓ Check your LangSmith dashboard to see the traces!"); + } + } + + /** + * Example 1: Single span LLM invocation. + * + *

+ * Creates a single span wrapping an OpenAI chat completion call with all + * required gen_ai attributes according to LangSmith conventions. + */ + private static void exampleSingleSpan(OpenAIClient client) { + OpenTelemetry openTelemetry = io.opentelemetry.api.GlobalOpenTelemetry.get(); + Tracer tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME); + + // Create span builder for LLM operation + Span span = tracer.spanBuilder("chat gpt-4o-mini") + .setSpanKind(SpanKind.CLIENT) + .startSpan(); + + try (Scope scope = span.makeCurrent()) { + // Set core gen_ai attributes for LLM type detection + span.setAttribute("gen_ai.system", "openai"); + span.setAttribute("gen_ai.operation.name", "chat"); + span.setAttribute("gen_ai.provider.name", "openai"); + + // Create chat completion request + String userMessage = "What is the capital of France?"; + ChatCompletionCreateParams params = ChatCompletionCreateParams.builder() + .model(ChatModel.GPT_4O_MINI) + .addUserMessage(userMessage) + .temperature(0.7) + .build(); + + // Set request attributes + span.setAttribute("gen_ai.request.model", "gpt-4o-mini"); + span.setAttribute("gen_ai.request.temperature", 0.7); + + // Set input messages as JSON array (following LangSmith format) + String inputMessagesJson = String.format( + "[{\"role\":\"user\",\"content\":\"%s\"}]", + escapeJsonString(userMessage)); + span.setAttribute("gen_ai.input.messages", inputMessagesJson); + + System.out.println("Making OpenAI API call..."); + // Make the actual API call + ChatCompletion completion = client.chat().completions().create(params); + + // Extract response data + String assistantContent = completion.choices().get(0).message().content() + .orElse("No content"); + String finishReason = completion.choices().get(0).finishReason() != null + ? completion.choices().get(0).finishReason().toString() + : "stop"; + + // Set response attributes + String responseModel = completion.model().toString(); + span.setAttribute("gen_ai.response.model", responseModel); + span.setAttribute("gen_ai.response.finish_reason", finishReason); + + // Set output messages as JSON array + String outputMessagesJson = String.format( + "[{\"role\":\"assistant\",\"content\":\"%s\"}]", + escapeJsonString(assistantContent)); + span.setAttribute("gen_ai.output.messages", outputMessagesJson); + + // Set token usage attributes + completion.usage().ifPresent(usage -> { + span.setAttribute("gen_ai.usage.input_tokens", (long) usage.promptTokens()); + span.setAttribute("gen_ai.usage.output_tokens", (long) usage.completionTokens()); + span.setAttribute("gen_ai.usage.total_tokens", (long) usage.totalTokens()); + }); + + System.out.println("Response: " + assistantContent); + completion.usage().ifPresent(usage -> { + System.out.println("Tokens - Input: " + usage.promptTokens() + + ", Output: " + usage.completionTokens() + + ", Total: " + usage.totalTokens()); + }); + + System.out.println("✓ Single span created successfully"); + + } catch (Exception e) { + // Record exception on span + span.recordException(e); + span.setAttribute("error", true); + throw e; + } finally { + span.end(); + } + } + + /** + * Example 2: Nested spans (root with two children). + * + *

+ * Creates a root span representing a workflow/chain, with two child spans + * representing individual LLM calls. This demonstrates parent-child + * relationships in LangSmith. + */ + private static void exampleNestedSpans(OpenAIClient client) { + + OpenTelemetry openTelemetry = io.opentelemetry.api.GlobalOpenTelemetry.get(); + Tracer tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME); + + // Create root span (chain/workflow) + Span rootSpan = tracer.spanBuilder("nested_spans_chain") + .setSpanKind(SpanKind.INTERNAL) + .setAttribute("gen_ai.operation.name", "nested_spans") + .setAttribute("langsmith.span.kind", "chain") + .setAttribute("langsmith.trace.name", "nested_spans_chain_" + System.currentTimeMillis()) + .startSpan(); + + try (Scope rootScope = rootSpan.makeCurrent()) { + // Set attributes for root span (chain type) + + System.out.println("Creating root span: nested_spans_chain"); + System.out.println("Root Span ID: " + rootSpan.getSpanContext().getSpanId()); + System.out.println("Root Trace ID: " + rootSpan.getSpanContext().getTraceId()); + + // Child span 1: First LLM call + System.out.println("\n--- Child Span 1: First Query ---"); + Span childSpan1 = tracer.spanBuilder("chat gpt-4o-mini (step 1)") + .setSpanKind(SpanKind.CLIENT) + .setAttribute("gen_ai.operation.name", "chat") + .setAttribute("langsmith.span.kind", "llm") + .setAttribute("langsmith.trace.name", "First LLM Call") + .startSpan(); + + try (Scope childScope1 = childSpan1.makeCurrent()) { + // Set LLM attributes for child span 1 + childSpan1.setAttribute("gen_ai.system", "openai"); + childSpan1.setAttribute("gen_ai.operation.name", "chat"); + childSpan1.setAttribute("gen_ai.provider.name", "openai"); + childSpan1.setAttribute("gen_ai.request.model", "gpt-4o-mini"); + childSpan1.setAttribute("gen_ai.request.temperature", 0.7); + + String query1 = "What is the capital of France?"; + ChatCompletionCreateParams params1 = ChatCompletionCreateParams.builder() + .model(ChatModel.GPT_4O_MINI) + .addUserMessage(query1) + .temperature(0.7) + .build(); + + String inputMessages1 = String.format( + "[{\"role\":\"user\",\"content\":\"%s\"}]", + escapeJsonString(query1)); + childSpan1.setAttribute("gen_ai.input.messages", inputMessages1); + + ChatCompletion completion1 = client.chat().completions().create(params1); + String response1 = completion1.choices().get(0).message().content() + .orElse("No content"); + + childSpan1.setAttribute("gen_ai.response.model", completion1.model().toString()); + String outputMessages1 = String.format( + "[{\"role\":\"assistant\",\"content\":\"%s\"}]", + escapeJsonString(response1)); + childSpan1.setAttribute("gen_ai.output.messages", outputMessages1); + + completion1.usage().ifPresent(usage -> { + childSpan1.setAttribute("gen_ai.usage.input_tokens", (long) usage.promptTokens()); + childSpan1.setAttribute("gen_ai.usage.output_tokens", (long) usage.completionTokens()); + childSpan1.setAttribute("gen_ai.usage.total_tokens", (long) usage.totalTokens()); + }); + + System.out.println("Query 1: " + query1); + System.out.println("Response 1: " + response1); + System.out.println("Child Span 1 ID: " + childSpan1.getSpanContext().getSpanId()); + System.out.println("Child Span 1 Parent: " + rootSpan.getSpanContext().getSpanId()); + } catch (Exception e) { + childSpan1.recordException(e); + childSpan1.setAttribute("error", true); + throw e; + } finally { + childSpan1.end(); + } + + // Child span 2: Second LLM call + System.out.println("\n--- Child Span 2: Follow-up Query ---"); + Span childSpan2 = tracer.spanBuilder("chat gpt-4o-mini (step 2)") + .setSpanKind(SpanKind.CLIENT) + .setAttribute("gen_ai.operation.name", "chat") + .setAttribute("langsmith.span.kind", "llm") + .setAttribute("langsmith.trace.name", "Second LLM Call") + .startSpan(); + + try (Scope childScope2 = childSpan2.makeCurrent()) { + // Set LLM attributes for child span 2 + childSpan2.setAttribute("gen_ai.system", "openai"); + childSpan2.setAttribute("gen_ai.operation.name", "chat"); + childSpan2.setAttribute("gen_ai.provider.name", "openai"); + childSpan2.setAttribute("gen_ai.request.model", "gpt-4o-mini"); + childSpan2.setAttribute("gen_ai.request.temperature", 0.7); + + String query2 = "What is the population of that city?"; + ChatCompletionCreateParams params2 = ChatCompletionCreateParams.builder() + .model(ChatModel.GPT_4O_MINI) + .addUserMessage(query2) + .temperature(0.7) + .build(); + + String inputMessages2 = String.format( + "[{\"role\":\"user\",\"content\":\"%s\"}]", + escapeJsonString(query2)); + childSpan2.setAttribute("gen_ai.input.messages", inputMessages2); + + ChatCompletion completion2 = client.chat().completions().create(params2); + String response2 = completion2.choices().get(0).message().content() + .orElse("No content"); + + childSpan2.setAttribute("gen_ai.response.model", completion2.model().toString()); + String outputMessages2 = String.format( + "[{\"role\":\"assistant\",\"content\":\"%s\"}]", + escapeJsonString(response2)); + childSpan2.setAttribute("gen_ai.output.messages", outputMessages2); + + completion2.usage().ifPresent(usage -> { + childSpan2.setAttribute("gen_ai.usage.input_tokens", (long) usage.promptTokens()); + childSpan2.setAttribute("gen_ai.usage.output_tokens", (long) usage.completionTokens()); + childSpan2.setAttribute("gen_ai.usage.total_tokens", (long) usage.totalTokens()); + }); + + System.out.println("Query 2: " + query2); + System.out.println("Response 2: " + response2); + System.out.println("Child Span 2 ID: " + childSpan2.getSpanContext().getSpanId()); + System.out.println("Child Span 2 Parent: " + rootSpan.getSpanContext().getSpanId()); + + } catch (Exception e) { + childSpan2.recordException(e); + childSpan2.setAttribute("error", true); + throw e; + } finally { + childSpan2.end(); + } + + // Child span 3: Tool call + System.out.println("\n--- Child Span 3: Tool call ---"); + Span childSpan3 = tracer.spanBuilder("tool_call") + .setSpanKind(SpanKind.CLIENT) + .setAttribute("gen_ai.operation.name", "tool_call") + .setAttribute("langsmith.span.kind", "tool") + .setAttribute("langsmith.trace.name", "Tool Call") + .startSpan(); + + try (Scope childScope3 = childSpan3.makeCurrent()) { + // Set tool call attributes for child span 3 + childSpan3.setAttribute("gen_ai.system", "openai"); + childSpan3.setAttribute("gen_ai.operation.name", "tool_call"); + childSpan3.setAttribute("gen_ai.provider.name", "openai"); + childSpan3.setAttribute("gen_ai.request.model", "gpt-4o-mini"); + childSpan3.setAttribute("gen_ai.request.temperature", 0.7); + + String toolName = "get_weather"; + String toolDescription = "Get the weather for a given city"; + String toolArguments = "{\"city\":\"Paris\"}"; + + childSpan3.setAttribute("gen_ai.tool.name", toolName); + childSpan3.setAttribute("gen_ai.tool.description", toolDescription); + childSpan3.setAttribute("gen_ai.tool.arguments", toolArguments); + + System.out.println("Tool Name: " + toolName); + System.out.println("Tool Description: " + toolDescription); + System.out.println("Tool Arguments: " + toolArguments); + System.out.println("Child Span 3 ID: " + childSpan3.getSpanContext().getSpanId()); + System.out.println("Child Span 3 Parent: " + rootSpan.getSpanContext().getSpanId()); + } catch (Exception e) { + childSpan3.recordException(e); + childSpan3.setAttribute("error", true); + throw e; + } finally { + childSpan3.end(); + } + + System.out.println("\n✓ Nested spans created successfully"); + System.out.println(" - Root span: multi-step-query (chain)"); + System.out.println(" - Child span 1: First LLM call"); + System.out.println(" - Child span 2: Second LLM call"); + System.out.println("\nAll spans ended, will be exported on flush..."); + + } catch (Exception e) { + rootSpan.recordException(e); + rootSpan.setAttribute("error", true); + throw e; + } finally { + rootSpan.end(); + } + } + + /** + * Repeats a string a given number of times (Java 8 compatible replacement for String.repeat()). + * + * @param str the string to repeat + * @param count the number of times to repeat + * @return the repeated string + */ + private static String repeatString(String str, int count) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + sb.append(str); + } + return sb.toString(); + } + + /** + * Escapes a JSON string by replacing special characters. + * + * @param str the string to escape + * @return the escaped string + */ + private 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-wrappers/src/main/java/com/langchain/smith/wrappers/openai/examples/WithLangSmithExample.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/examples/WithLangSmithExample.java new file mode 100644 index 00000000..05f3b9c8 --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/openai/examples/WithLangSmithExample.java @@ -0,0 +1,315 @@ +package com.langchain.smith.wrappers.openai.examples; + +import com.langchain.smith.wrappers.openai.OpenTelemetryConfig; +import com.langchain.smith.wrappers.openai.WrappedOpenAIClient; +import com.langchain.smith.wrappers.openai.OpenAIWrappers; +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.chat.completions.ChatCompletionFunctionTool; +import com.openai.models.chat.completions.ChatCompletionTool; +import com.openai.models.chat.completions.ChatCompletionToolChoiceOption; +import com.openai.models.chat.completions.ChatCompletionMessageToolCall; +import com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall; +import com.openai.models.FunctionDefinition; +import com.openai.models.FunctionParameters; +import com.openai.models.ChatModel; +import com.openai.core.JsonValue; +import java.util.Map; +import java.util.HashMap; +import java.util.ArrayList; +import java.util.List; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import java.util.Arrays; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Example demonstrating how to configure OpenTelemetry to send traces to + * LangSmith. + * + *

+ * This example shows how to: + *

    + *
  • Configure OpenTelemetry to export traces to LangSmith
  • + *
  • Use the wrapped OpenAI client with automatic tracing
  • + *
  • Traces will appear in your LangSmith dashboard
  • + *
+ */ +public class WithLangSmithExample { + + /** + * Main method demonstrating LangSmith integration. + * + *

+ * Prerequisites: + *

    + *
  1. Set OPENAI_API_KEY environment variable
  2. + *
  3. Set LANGSMITH_API_KEY environment variable (your LangSmith API key)
  4. + *
  5. Optionally set LANGSMITH_PROJECT environment variable
  6. + *
  7. Optionally set LANGSMITH_OTLP_ENDPOINT environment variable to override + * the default endpoint
  8. + *
  9. Optionally set LANGSMITH_DEBUG=true environment variable for detailed + * logging
  10. + *
+ * + * @param args command line arguments (not used) + */ + public static void main(String[] args) { + // Configure OpenTelemetry for LangSmith with SimpleSpanProcessor for immediate + // export + // This ensures spans are sent immediately without buffering + // To enable debug logging, set LANGSMITH_DEBUG=true environment variable + System.out.println("Configuring OpenTelemetry for LangSmith..."); + try { + String apiKey = System.getenv("LANGSMITH_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalStateException("LANGSMITH_API_KEY environment variable is required"); + } + String projectName = System.getenv("LANGSMITH_PROJECT"); + String serviceName = System.getenv("OTEL_SERVICE_NAME"); + String endpoint = System.getenv("LANGSMITH_OTLP_ENDPOINT"); + + // Option 1: Use SimpleSpanProcessor for synchronous, immediate export + // This ensures spans are sent immediately without buffering + // SimpleSpanProcessor blocks on span.end() until export completes + // This is the recommended configuration for examples and short-lived + // applications + OpenTelemetryConfig.configureForLangSmith(apiKey, projectName, serviceName, endpoint, + OpenTelemetryConfig.SpanProcessorType.SIMPLE, 1); + System.out.println("✓ OpenTelemetry configured with SimpleSpanProcessor (immediate export)\n"); + + // Option 2: Use BatchSpanProcessor with batch size = 1 for non-blocking + // immediate export + // Uncomment this line and comment the SimpleSpanProcessor configuration above + // to test + // This is non-blocking but still exports immediately due to batch size = 1 + // OpenTelemetryConfig.configureForLangSmith(apiKey, projectName, serviceName, + // endpoint, + // OpenTelemetryConfig.SpanProcessorType.BATCH, 1); + // System.out.println("✓ OpenTelemetry configured with BatchSpanProcessor (batch + // size = 1)\n"); + } catch (IllegalStateException e) { + System.err.println("✗ Error configuring OpenTelemetry: " + e.getMessage()); + System.err.println("\nPlease set the following environment variables:"); + System.err.println(" - LANGSMITH_API_KEY (required)"); + System.err.println(" - LANGSMITH_PROJECT (optional)"); + return; + } + + // Now use the wrapped client - all API calls will automatically create spans + // that are sent to LangSmith + WrappedOpenAIClient client = OpenAIWrappers.wrapFromEnv(); + + // Create a chat completion request with tool definitions + // This will trigger tool calls when the model needs to use the get_weather tool + // Build function parameters as JSON schema + Map properties = new HashMap<>(); + Map locationProperty = new HashMap<>(); + locationProperty.put("type", JsonValue.from("string")); + locationProperty.put("description", + JsonValue.from("The location to get weather for, e.g., 'Paris arrondissement 9'")); + 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"))); + + ChatCompletionCreateParams params = ChatCompletionCreateParams.builder() + .model(ChatModel.GPT_4O_MINI) + .addUserMessage("What is the capital of France and what was the temperature there today?") + .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(); + + // Create a parent span (chain/workflow) that wraps the entire operation + OpenTelemetry openTelemetry = io.opentelemetry.api.GlobalOpenTelemetry.get(); + Tracer tracer = openTelemetry.getTracer("langsmith-java-otel-wrappers"); + + Span parentSpan = tracer.spanBuilder("with_langsmith_example_workflow") + .setSpanKind(SpanKind.INTERNAL) + .setAttribute("gen_ai.operation.name", "chat_completion_workflow") + .setAttribute("langsmith.span.kind", "chain") + .setAttribute("langsmith.trace.name", "with_langsmith_example_workflow") + .startSpan(); + + try (Scope parentScope = parentSpan.makeCurrent()) { + System.out.println("\n" + repeatString("=", 60)); + System.out.println("Workflow: Chat Completion with Tool Call"); + System.out.println(repeatString("=", 60)); + System.out.println("Parent Span ID: " + parentSpan.getSpanContext().getSpanId()); + System.out.println("Parent Trace ID: " + parentSpan.getSpanContext().getTraceId()); + + // Execute the request - this will automatically create an OpenTelemetry span + // that gets sent to LangSmith (as a child of the parent span) + System.out.println("\nSending chat completion request (traces will be sent to LangSmith)..."); + ChatCompletion completion = client.chat().completions().create(params); + + // Check if the response contains tool calls + com.openai.models.chat.completions.ChatCompletionMessage message = completion.choices().get(0).message(); + java.util.Optional> toolCallsOpt = message.toolCalls(); + + if (toolCallsOpt.isPresent() && !toolCallsOpt.get().isEmpty()) { + System.out.println("\n--- Tool calls detected, executing tools ---"); + java.util.List toolCalls = toolCallsOpt.get(); + + // Build messages list for follow-up request + List messages = new ArrayList<>(); + + // Add original user message + messages.add(params.messages().get(0)); + + // Add assistant message with tool calls + messages.add(com.openai.models.chat.completions.ChatCompletionMessageParam.ofAssistant( + com.openai.models.chat.completions.ChatCompletionAssistantMessageParam.builder() + .content(message.content().orElse("")) + .toolCalls(toolCalls) + .build())); + + // Execute each tool call and add results + for (ChatCompletionMessageToolCall toolCall : toolCalls) { + if (toolCall.isFunction()) { + ChatCompletionMessageFunctionToolCall functionToolCall = toolCall.asFunction(); + String toolName = functionToolCall.function().name(); + String toolArguments = functionToolCall.function().arguments(); + + System.out.println("Executing tool: " + toolName + " with arguments: " + toolArguments); + + // Execute the tool + String toolResult = executeTool(toolName, toolArguments); + + System.out.println("Tool result: " + toolResult); + + // Add tool result message + messages.add(com.openai.models.chat.completions.ChatCompletionMessageParam.ofTool( + com.openai.models.chat.completions.ChatCompletionToolMessageParam.builder() + .toolCallId(functionToolCall.id()) + .content(toolResult) + .build())); + } + } + + // Send follow-up request with tool results + System.out.println("\nSending follow-up request with tool results..."); + ChatCompletionCreateParams followUpParams = ChatCompletionCreateParams.builder() + .model(ChatModel.GPT_4O_MINI) + .messages(messages) + .tools(params.tools().orElse(null)) + .build(); + + completion = client.chat().completions().create(followUpParams); + } + + // Display the result + String content = completion.choices().get(0).message().content() + .orElse("No content"); + System.out.println("\nFinal Response: " + content); + + // Usage information + completion.usage().ifPresent(usage -> { + System.out.println("\nToken usage:"); + System.out.println(" Input tokens: " + usage.promptTokens()); + System.out.println(" Output tokens: " + usage.completionTokens()); + System.out.println(" Total tokens: " + usage.totalTokens()); + }); + + // Note: Tool call spans are automatically created by WrappedChatService + // when tool calls are detected in the response. No manual span creation needed! + + System.out.println("\n✓ Workflow completed successfully"); + System.out.println(" - Parent span: workflow (chain)"); + System.out.println(" - Child span: Chat completion (LLM) - automatically created by WrappedChatService"); + System.out.println(" - Tool call spans: Automatically created if tool calls are present in the response"); + } catch (Exception e) { + parentSpan.recordException(e); + parentSpan.setAttribute("error", true); + System.err.println("✗ Error in workflow: " + e.getMessage()); + e.printStackTrace(); + } finally { + parentSpan.end(); + System.out.println("\n✓ Parent span ended"); + } + + // Close the client when done + client.close(); + + // Force flush to ensure all spans are exported + System.out.println("\n" + repeatString("=", 60)); + boolean flushed = OpenTelemetryConfig.flush(10, java.util.concurrent.TimeUnit.SECONDS); + if (!flushed) { + System.err.println("✗ Warning: Flush did not complete successfully"); + System.err.println(" Some spans may not have been exported to LangSmith"); + } else { + System.out.println("✓ Spans flushed successfully"); + } + + System.out.println("\n✓ Check your LangSmith dashboard to see the traces!"); + System.out.println(" Endpoint: " + (System.getenv("LANGSMITH_OTLP_ENDPOINT") != null + ? System.getenv("LANGSMITH_OTLP_ENDPOINT") + : OpenTelemetryConfig.LANGSMITH_OTLP_ENDPOINT)); + } + + /** + * Repeats a string a given number of times (Java 8 compatible replacement for + * String.repeat()). + * + * @param str the string to repeat + * @param count the number of times to repeat + * @return the repeated string + */ + private static String repeatString(String str, int count) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + sb.append(str); + } + return sb.toString(); + } + + /** + * Executes 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 + // In a real implementation, this would call an actual weather API + Map result = new HashMap<>(); + result.put("location", location); + result.put("temperature", "15°C"); + result.put("condition", "Sunny"); + result.put("humidity", "65%"); + + 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-wrappers/src/main/java/com/langchain/smith/wrappers/package-info.java b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/package-info.java new file mode 100644 index 00000000..e75c4da8 --- /dev/null +++ b/langsmith-java-wrappers/src/main/java/com/langchain/smith/wrappers/package-info.java @@ -0,0 +1,7 @@ +/** + * LangSmith OpenTelemetry Wrappers for Java. + * + *

+ * This package provides OpenTelemetry integration wrappers for LangSmith. + */ +package com.langchain.smith.wrappers; diff --git a/langsmith-java-wrappers/src/test/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClientTest.java b/langsmith-java-wrappers/src/test/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClientTest.java new file mode 100644 index 00000000..2c3c9c5f --- /dev/null +++ b/langsmith-java-wrappers/src/test/java/com/langchain/smith/wrappers/openai/WrappedOpenAIClientTest.java @@ -0,0 +1,67 @@ +package com.langchain.smith.wrappers.openai; + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the WrappedOpenAIClient wrapper. + */ +class WrappedOpenAIClientTest { + + @Test + void testWrapWithNullClientThrowsException() { + assertThrows(IllegalArgumentException.class, () -> { + new WrappedOpenAIClient(null); + }); + } + + @Test + void testWrapDelegatesToOriginalClient() { + // Create a mock or use a real client if API key is available + // For now, we'll test that the wrapper can be created + // In a real scenario, you'd need an API key to test with an actual client + + // Test that builder works + WrappedOpenAIClient.Builder builder = WrappedOpenAIClient.builder(); + assertNotNull(builder); + + // Test that fromEnv() method exists (will fail at runtime if env vars not set, but that's expected) + // WrappedOpenAIClient client = WrappedOpenAIClient.fromEnv(); + } + + @Test + void testBuilderDelegatesToOpenAIClientBuilder() { + WrappedOpenAIClient.Builder builder = WrappedOpenAIClient.builder(); + + // Test that builder methods exist and can be chained + builder.apiKey("test-key"); + builder.organization("test-org"); + builder.project("test-project"); + builder.baseUrl("https://api.openai.com/v1"); + + // Build should succeed (even if API key is invalid) + assertDoesNotThrow(() -> builder.build()); + } + + @Test + void testOpenAIWrappersWrapMethod() { + // Create a minimal client to wrap + OpenAIClient originalClient = OpenAIOkHttpClient.builder() + .apiKey("test-key") + .build(); + + WrappedOpenAIClient wrapped = OpenAIWrappers.wrap(originalClient); + assertNotNull(wrapped); + assertEquals(originalClient, wrapped.getDelegate()); + } + + @Test + void testOpenAIWrappersWrapWithNullThrowsException() { + assertThrows(IllegalArgumentException.class, () -> { + OpenAIWrappers.wrap(null); + }); + } +} + diff --git a/langsmith-java-wrappers/src/test/java/com/langchain/smith/wrappers/package-info.java b/langsmith-java-wrappers/src/test/java/com/langchain/smith/wrappers/package-info.java new file mode 100644 index 00000000..1cce08c7 --- /dev/null +++ b/langsmith-java-wrappers/src/test/java/com/langchain/smith/wrappers/package-info.java @@ -0,0 +1,5 @@ +/** + * Test package for LangSmith OpenTelemetry Wrappers. + */ +package com.langchain.smith.wrappers; +