diff --git a/README.md b/README.md
index 0a9aa10e..4b07f152 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,45 @@ public class DisableTLSExample {
}
```
+#### Capturing response metadata for observability
+
+The SDK supports capturing response metadata for all data plane operations (upsert, query, fetch, update, delete). This enables you to track latency metrics and integrate with observability tools like OpenTelemetry, Prometheus, or Datadog.
+
+```java
+import io.pinecone.clients.Pinecone;
+import io.pinecone.clients.Index;
+
+Pinecone pinecone = new Pinecone.Builder("PINECONE_API_KEY")
+ .withResponseMetadataListener(metadata -> {
+ System.out.printf("Operation: %s | Client: %dms | Server: %dms | Network: %dms%n",
+ metadata.getOperationName(),
+ metadata.getClientDurationMs(),
+ metadata.getServerDurationMs(),
+ metadata.getNetworkOverheadMs());
+ })
+ .build();
+
+Index index = pinecone.getIndexConnection("example-index");
+index.query(5, Arrays.asList(1.0f, 2.0f, 3.0f));
+// Output: Operation: query | Client: 45ms | Server: 32ms | Network: 13ms
+```
+
+The `ResponseMetadata` object provides:
+
+| Method | Description |
+|--------|-------------|
+| `getOperationName()` | Operation type: upsert, query, fetch, update, delete |
+| `getClientDurationMs()` | Total round-trip time measured by the client |
+| `getServerDurationMs()` | Server processing time from `x-pinecone-response-duration-ms` header |
+| `getNetworkOverheadMs()` | Computed: client duration - server duration |
+| `getIndexName()` | Name of the index |
+| `getNamespace()` | Namespace used |
+| `isSuccess()` | Whether the operation succeeded |
+| `getGrpcStatusCode()` | gRPC status code |
+| `getErrorType()` | Error category when failed |
+
+For a complete OpenTelemetry integration example with Prometheus and Grafana, see the [java-otel-metrics example](examples/java-otel-metrics/).
+
# Indexes
Operations related to the building and managing of Pinecone indexes are called [control plane](https://docs.pinecone.io/reference/api/introduction#control-plane) operations.
diff --git a/examples/build.gradle b/examples/build.gradle
index 82b10a16..58b39d36 100644
--- a/examples/build.gradle
+++ b/examples/build.gradle
@@ -10,20 +10,28 @@ repositories {
jcenter()
}
+def opentelemetryVersion = '1.35.0'
+
dependencies {
implementation project(':pinecone-client')
implementation "org.slf4j:slf4j-simple:1.7.30"
implementation 'org.codehaus.groovy:groovy-all:2.4.15'
+
+ // OpenTelemetry dependencies for java-otel-metrics example
+ implementation "io.opentelemetry:opentelemetry-sdk:${opentelemetryVersion}"
+ implementation "io.opentelemetry:opentelemetry-sdk-metrics:${opentelemetryVersion}"
+ implementation "io.opentelemetry:opentelemetry-exporter-otlp:${opentelemetryVersion}"
+ implementation "io.opentelemetry:opentelemetry-exporter-logging:${opentelemetryVersion}"
}
sourceSets {
main {
java {
- srcDirs = ['java-basic-mvn/src']
+ srcDirs = ['java-basic-mvn/src', 'java-otel-metrics/src/main/java']
}
groovy {
srcDirs = ['groovy-basic']
}
}
-}
\ No newline at end of file
+}
diff --git a/examples/java-otel-metrics/README.md b/examples/java-otel-metrics/README.md
new file mode 100644
index 00000000..0d02689b
--- /dev/null
+++ b/examples/java-otel-metrics/README.md
@@ -0,0 +1,151 @@
+# Pinecone Java SDK - OpenTelemetry Metrics Example
+
+This example demonstrates how to integrate OpenTelemetry metrics with the Pinecone Java SDK using the `ResponseMetadataListener` feature. It captures latency metrics for all data plane operations and exports them to Prometheus/Grafana for visualization.
+
+## What This Example Does
+
+- Captures **client-side latency** (total round-trip time) for Pinecone operations
+- Captures **server-side latency** from the `x-pinecone-response-duration-ms` header
+- Calculates **network overhead** (client - server duration)
+- Exports metrics to OpenTelemetry-compatible backends (Prometheus, Grafana, Datadog, etc.)
+
+## Metrics Recorded
+
+| Metric | Type | Description |
+|--------|------|-------------|
+| `db.client.operation.duration` | Histogram | Client-measured round-trip time (ms) |
+| `pinecone.server.processing.duration` | Histogram | Server processing time from header (ms) |
+| `db.client.operation.count` | Counter | Total number of operations |
+
+### Attributes
+
+| Attribute | Description |
+|-----------|-------------|
+| `db.system` | Always "pinecone" |
+| `db.operation.name` | Operation type (upsert, query, fetch, update, delete) |
+| `db.namespace` | Pinecone namespace |
+| `pinecone.index_name` | Index name |
+| `server.address` | Pinecone host |
+| `status` | "success" or "error" |
+
+## Prerequisites
+
+- Java 8+
+- Maven 3.6+
+- Docker and Docker Compose
+- A Pinecone account with an API key and index
+
+## Project Structure
+
+```
+java-otel-metrics/
+├── pom.xml # Maven dependencies
+├── README.md # This file
+├── observability/ # Local observability stack
+│ ├── docker-compose.yml # Prometheus + Grafana + OTel Collector
+│ ├── otel-collector-config.yaml # OTel Collector configuration
+│ └── prometheus.yml # Prometheus scrape config
+└── src/main/java/pineconeexamples/
+ ├── PineconeOtelMetricsExample.java # Main example
+ └── PineconeMetricsRecorder.java # Reusable metrics recorder
+```
+
+## Quick Start
+
+### 1. Start the Observability Stack
+
+```bash
+cd examples/java-otel-metrics/observability
+docker-compose up -d
+```
+
+This starts:
+- **OpenTelemetry Collector** (port 4317) - receives metrics via OTLP
+- **Prometheus** (port 9090) - stores metrics
+- **Grafana** (port 3000) - visualizes metrics
+
+### 2. Run the Example
+
+```bash
+cd examples/java-otel-metrics
+
+export PINECONE_API_KEY=your-api-key
+export PINECONE_INDEX=your-index-name
+export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
+
+mvn package exec:java -Dexec.mainClass="pineconeexamples.PineconeOtelMetricsExample"
+```
+
+### 3. View Metrics in Grafana
+
+1. Open http://localhost:3000
+2. Login with `admin` / `admin`
+3. Go to **Connections** → **Data sources** → **Add data source**
+4. Select **Prometheus**, set URL to `http://prometheus:9090`, click **Save & test**
+5. Go to **Dashboards** → **New** → **New Dashboard** → **Add visualization**
+
+### 4. Sample Grafana Queries
+
+**P50 Client vs Server Latency:**
+```promql
+histogram_quantile(0.5, sum(rate(db_client_operation_duration_milliseconds_bucket[5m])) by (le))
+histogram_quantile(0.5, sum(rate(pinecone_server_processing_duration_milliseconds_bucket[5m])) by (le))
+```
+
+**P95 Latency by Operation:**
+```promql
+histogram_quantile(0.95, sum(rate(db_client_operation_duration_milliseconds_bucket[5m])) by (le, db_operation_name))
+```
+
+**Operation Count by Type:**
+```promql
+sum by (db_operation_name) (db_client_operation_count_total)
+```
+
+## Understanding the Metrics
+
+### Percentiles Explained
+
+| Percentile | Meaning |
+|------------|---------|
+| P50 | Median - typical latency |
+| P90 | 90% of requests are faster |
+| P95 | Tail latency - good for SLAs |
+| P99 | Worst-case for most users |
+
+### Network Overhead
+
+The difference between client and server duration shows network overhead:
+
+```
+Network Overhead = Client Duration - Server Duration
+```
+
+This helps identify whether latency issues are:
+- **Server-side** (high server duration)
+- **Network-side** (high network overhead)
+
+## Cleanup
+
+```bash
+cd examples/java-otel-metrics/observability
+docker-compose down
+```
+
+## Using in Your Project
+
+Copy `PineconeMetricsRecorder.java` into your project:
+
+```java
+Meter meter = meterProvider.get("pinecone.client");
+PineconeMetricsRecorder recorder = new PineconeMetricsRecorder(meter);
+
+Pinecone client = new Pinecone.Builder(apiKey)
+ .withResponseMetadataListener(recorder)
+ .build();
+
+// All operations now emit metrics automatically
+Index index = client.getIndexConnection(indexName);
+index.upsert(...); // Metrics recorded!
+index.query(...); // Metrics recorded!
+```
diff --git a/examples/java-otel-metrics/observability/docker-compose.yml b/examples/java-otel-metrics/observability/docker-compose.yml
new file mode 100644
index 00000000..9773a7dd
--- /dev/null
+++ b/examples/java-otel-metrics/observability/docker-compose.yml
@@ -0,0 +1,30 @@
+version: '3.8'
+
+services:
+ otel-collector:
+ image: otel/opentelemetry-collector-contrib:0.96.0
+ command: ["--config=/etc/otel-collector-config.yaml"]
+ volumes:
+ - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
+ ports:
+ - "4317:4317" # OTLP gRPC receiver
+
+ prometheus:
+ image: prom/prometheus:v2.49.1
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
+ ports:
+ - "9090:9090" # Prometheus UI
+ depends_on:
+ - otel-collector
+
+ grafana:
+ image: grafana/grafana:10.3.1
+ ports:
+ - "3000:3000" # Grafana UI
+ environment:
+ - GF_SECURITY_ADMIN_PASSWORD=admin
+ - GF_AUTH_ANONYMOUS_ENABLED=true
+ depends_on:
+ - prometheus
+
diff --git a/examples/java-otel-metrics/observability/otel-collector-config.yaml b/examples/java-otel-metrics/observability/otel-collector-config.yaml
new file mode 100644
index 00000000..5d0c9623
--- /dev/null
+++ b/examples/java-otel-metrics/observability/otel-collector-config.yaml
@@ -0,0 +1,18 @@
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+
+exporters:
+ prometheus:
+ endpoint: "0.0.0.0:8889"
+ debug:
+ verbosity: detailed
+
+service:
+ pipelines:
+ metrics:
+ receivers: [otlp]
+ exporters: [prometheus, debug]
+
diff --git a/examples/java-otel-metrics/observability/prometheus.yml b/examples/java-otel-metrics/observability/prometheus.yml
new file mode 100644
index 00000000..d3c34083
--- /dev/null
+++ b/examples/java-otel-metrics/observability/prometheus.yml
@@ -0,0 +1,9 @@
+global:
+ scrape_interval: 5s
+ evaluation_interval: 5s
+
+scrape_configs:
+ - job_name: 'otel-collector'
+ static_configs:
+ - targets: ['otel-collector:8889']
+
diff --git a/examples/java-otel-metrics/pom.xml b/examples/java-otel-metrics/pom.xml
new file mode 100644
index 00000000..4e7fb7d5
--- /dev/null
+++ b/examples/java-otel-metrics/pom.xml
@@ -0,0 +1,98 @@
+
+
+
This class implements {@link ResponseMetadataListener} to capture response metadata + * from Pinecone data plane operations and record them as OpenTelemetry metrics. + * + *
Metrics recorded: + *
Attributes follow OpenTelemetry semantic conventions for database clients: + *
Example usage: + *
{@code
+ * Meter meter = meterProvider.get("pinecone.client");
+ * PineconeMetricsRecorder recorder = new PineconeMetricsRecorder(meter);
+ *
+ * Pinecone client = new Pinecone.Builder(apiKey)
+ * .withResponseMetadataListener(recorder)
+ * .build();
+ * }
+ *
+ * You can copy this class into your project and customize it as needed.
+ */
+public class PineconeMetricsRecorder implements ResponseMetadataListener {
+
+ // Attribute keys following OTel semantic conventions
+ private static final AttributeKey This example shows:
+ * Environment variables:
+ * Run with:
+ *
+ * The listener is invoked after each upsert, query, fetch, update, or delete operation completes.
+ * Use this for custom metrics, logging, or OpenTelemetry integration.
+ *
+ * Example usage:
+ *
+ *
+ * @param config The {@link PineconeConfig} containing configuration settings for the PineconeConnection.
+ * @param indexName The name of the index, used for response metadata tracking.
+ * @throws PineconeValidationException If index name or host is not provided for data plane operations.
+ */
+ public PineconeConnection(PineconeConfig config, String indexName) {
this.config = config;
+ this.indexName = indexName;
if (config.getCustomManagedChannel() != null) {
channel = config.getCustomManagedChannel();
} else {
@@ -91,19 +107,41 @@ private void initialize() {
}
private VectorServiceGrpc.VectorServiceBlockingStub generateBlockingStub(Metadata metadata) {
- return VectorServiceGrpc
+ VectorServiceGrpc.VectorServiceBlockingStub stub = VectorServiceGrpc
.newBlockingStub(channel)
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
.withMaxInboundMessageSize(DEFAULT_MAX_MESSAGE_SIZE)
.withMaxOutboundMessageSize(DEFAULT_MAX_MESSAGE_SIZE);
+
+ // Add response metadata interceptor if listener is configured
+ if (config.getResponseMetadataListener() != null) {
+ stub = stub.withInterceptors(
+ new ResponseMetadataInterceptor(
+ config.getResponseMetadataListener(),
+ indexName != null ? indexName : "",
+ config.getHost() != null ? config.getHost() : ""));
+ }
+
+ return stub;
}
private VectorServiceGrpc.VectorServiceFutureStub generateAsyncStub(Metadata metadata) {
- return VectorServiceGrpc
+ VectorServiceGrpc.VectorServiceFutureStub stub = VectorServiceGrpc
.newFutureStub(channel)
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
.withMaxInboundMessageSize(DEFAULT_MAX_MESSAGE_SIZE)
.withMaxOutboundMessageSize(DEFAULT_MAX_MESSAGE_SIZE);
+
+ // Add response metadata interceptor if listener is configured
+ if (config.getResponseMetadataListener() != null) {
+ stub = stub.withInterceptors(
+ new ResponseMetadataInterceptor(
+ config.getResponseMetadataListener(),
+ indexName != null ? indexName : "",
+ config.getHost() != null ? config.getHost() : ""));
+ }
+
+ return stub;
}
/**
diff --git a/src/main/java/io/pinecone/configs/ResponseMetadata.java b/src/main/java/io/pinecone/configs/ResponseMetadata.java
new file mode 100644
index 00000000..3b4f14dd
--- /dev/null
+++ b/src/main/java/io/pinecone/configs/ResponseMetadata.java
@@ -0,0 +1,230 @@
+package io.pinecone.configs;
+
+/**
+ * Captures response metadata from Pinecone data plane operations.
+ * Contains timing information for observability and monitoring.
+ *
+ * This class provides:
+ * Example usage with a listener:
+ * Implement this interface to capture timing metrics for observability purposes.
+ * The listener is invoked after each data plane operation completes (success or failure).
+ *
+ * Supported operations:
+ * Example - Simple logging:
+ * Example - OpenTelemetry integration:
+ * Example - Micrometer/Prometheus:
+ * This method is called synchronously after the gRPC response is received.
+ * Implementations should be lightweight and non-blocking to avoid impacting
+ * request latency. For heavy processing, consider queuing the metadata for
+ * async handling.
+ *
+ * Exceptions thrown by this method are logged but do not affect the
+ * operation result.
+ *
+ * @param metadata The response metadata containing timing and operation details
+ */
+ void onResponse(ResponseMetadata metadata);
+}
+
diff --git a/src/test/java/io/pinecone/configs/ResponseMetadataTest.java b/src/test/java/io/pinecone/configs/ResponseMetadataTest.java
new file mode 100644
index 00000000..1695dd4e
--- /dev/null
+++ b/src/test/java/io/pinecone/configs/ResponseMetadataTest.java
@@ -0,0 +1,184 @@
+package io.pinecone.configs;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ResponseMetadataTest {
+
+ @Test
+ void testBuilderWithAllFields() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("query")
+ .indexName("test-index")
+ .namespace("test-namespace")
+ .serverAddress("test-index-abc.svc.pinecone.io")
+ .clientDurationMs(150)
+ .serverDurationMs(100L)
+ .status("success")
+ .grpcStatusCode("OK")
+ .build();
+
+ assertEquals("query", metadata.getOperationName());
+ assertEquals("test-index", metadata.getIndexName());
+ assertEquals("test-namespace", metadata.getNamespace());
+ assertEquals("test-index-abc.svc.pinecone.io", metadata.getServerAddress());
+ assertEquals(150, metadata.getClientDurationMs());
+ assertEquals(100L, metadata.getServerDurationMs());
+ assertEquals("success", metadata.getStatus());
+ assertEquals("OK", metadata.getGrpcStatusCode());
+ assertTrue(metadata.isSuccess());
+ assertNull(metadata.getErrorType());
+ }
+
+ @Test
+ void testNetworkOverheadCalculation() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("upsert")
+ .clientDurationMs(200)
+ .serverDurationMs(150L)
+ .build();
+
+ assertEquals(50L, metadata.getNetworkOverheadMs());
+ }
+
+ @Test
+ void testNetworkOverheadNullWhenServerDurationMissing() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("upsert")
+ .clientDurationMs(200)
+ .serverDurationMs(null)
+ .build();
+
+ assertNull(metadata.getNetworkOverheadMs());
+ }
+
+ @Test
+ void testErrorMetadata() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("query")
+ .indexName("test-index")
+ .clientDurationMs(50)
+ .status("error")
+ .grpcStatusCode("UNAVAILABLE")
+ .errorType("connection")
+ .build();
+
+ assertFalse(metadata.isSuccess());
+ assertEquals("error", metadata.getStatus());
+ assertEquals("UNAVAILABLE", metadata.getGrpcStatusCode());
+ assertEquals("connection", metadata.getErrorType());
+ }
+
+ @Test
+ void testDefaultNamespaceIsEmptyString() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("fetch")
+ .namespace(null)
+ .build();
+
+ assertEquals("", metadata.getNamespace());
+ }
+
+ @Test
+ void testDefaultStatusIsSuccess() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("delete")
+ .build();
+
+ assertEquals("success", metadata.getStatus());
+ assertEquals("OK", metadata.getGrpcStatusCode());
+ assertTrue(metadata.isSuccess());
+ }
+
+ @Test
+ void testToStringContainsKeyFields() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("delete")
+ .indexName("my-index")
+ .namespace("ns1")
+ .clientDurationMs(100)
+ .serverDurationMs(80L)
+ .status("success")
+ .build();
+
+ String str = metadata.toString();
+ assertTrue(str.contains("operation=delete"));
+ assertTrue(str.contains("index=my-index"));
+ assertTrue(str.contains("namespace=ns1"));
+ assertTrue(str.contains("clientDurationMs=100"));
+ assertTrue(str.contains("serverDurationMs=80"));
+ assertTrue(str.contains("networkOverheadMs=20"));
+ }
+
+ @Test
+ void testToStringWithoutServerDuration() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("query")
+ .indexName("my-index")
+ .clientDurationMs(100)
+ .serverDurationMs(null)
+ .status("success")
+ .build();
+
+ String str = metadata.toString();
+ assertTrue(str.contains("operation=query"));
+ assertTrue(str.contains("clientDurationMs=100"));
+ assertFalse(str.contains("serverDurationMs"));
+ assertFalse(str.contains("networkOverheadMs"));
+ }
+
+ @Test
+ void testToStringWithError() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("upsert")
+ .indexName("my-index")
+ .clientDurationMs(50)
+ .status("error")
+ .errorType("rate_limit")
+ .build();
+
+ String str = metadata.toString();
+ assertTrue(str.contains("status=error"));
+ assertTrue(str.contains("errorType=rate_limit"));
+ }
+
+ @Test
+ void testToStringWithoutNamespace() {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("fetch")
+ .indexName("my-index")
+ .namespace("")
+ .clientDurationMs(100)
+ .build();
+
+ String str = metadata.toString();
+ assertFalse(str.contains("namespace="));
+ }
+
+ @Test
+ void testAllOperationTypes() {
+ String[] operations = {"upsert", "query", "fetch", "update", "delete"};
+ for (String op : operations) {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName(op)
+ .build();
+ assertEquals(op, metadata.getOperationName());
+ }
+ }
+
+ @Test
+ void testAllErrorTypes() {
+ String[] errorTypes = {"validation", "connection", "server", "rate_limit", "timeout", "auth", "not_found", "unknown"};
+ for (String errorType : errorTypes) {
+ ResponseMetadata metadata = ResponseMetadata.builder()
+ .operationName("query")
+ .status("error")
+ .errorType(errorType)
+ .build();
+ assertEquals(errorType, metadata.getErrorType());
+ assertFalse(metadata.isSuccess());
+ }
+ }
+}
+
+ *
+ *
+ *
+ *
+ *
+ *
+ * mvn package exec:java -Dexec.mainClass="pineconeexamples.PineconeOtelMetricsExample"
+ *
+ */
+public class PineconeOtelMetricsExample {
+
+ private static final String SERVICE_NAME = "pinecone-otel-example";
+
+ public static void main(String[] args) {
+ // Read configuration from environment
+ String apiKey = getRequiredEnv("PINECONE_API_KEY");
+ String indexName = getRequiredEnv("PINECONE_INDEX");
+ String otlpEndpoint = System.getenv("OTEL_EXPORTER_OTLP_ENDPOINT");
+
+ System.out.println("============================================================");
+ System.out.println("Pinecone OpenTelemetry Metrics Example");
+ System.out.println("============================================================");
+ System.out.println("Index: " + indexName);
+ System.out.println("OTLP Endpoint: " + (otlpEndpoint != null ? otlpEndpoint : "(not configured - console only)"));
+ System.out.println();
+
+ // Initialize OpenTelemetry
+ OpenTelemetrySdk openTelemetry = initializeOpenTelemetry(otlpEndpoint);
+
+ try {
+ // Get a Meter for creating instruments
+ Meter meter = openTelemetry.getMeter("pinecone.client");
+
+ // Create the metrics recorder
+ PineconeMetricsRecorder metricsRecorder = new PineconeMetricsRecorder(meter);
+
+ // Build Pinecone client with the metrics recorder as listener
+ Pinecone pinecone = new Pinecone.Builder(apiKey)
+ .withResponseMetadataListener(metricsRecorder)
+ .build();
+
+ // Get index connection
+ Index index = pinecone.getIndexConnection(indexName);
+
+ System.out.println("Performing Pinecone operations...");
+ System.out.println();
+
+ // Perform sample operations
+ performSampleOperations(index);
+
+ // Close the index connection
+ index.close();
+
+ System.out.println();
+ System.out.println("Operations complete. Flushing metrics...");
+ System.out.println();
+
+ // Give metrics time to export (periodic reader exports every 10 seconds by default)
+ // Force a flush by waiting a bit
+ Thread.sleep(2000);
+
+ } catch (Exception e) {
+ System.err.println("Error: " + e.getMessage());
+ e.printStackTrace();
+ } finally {
+ // Shutdown OpenTelemetry SDK
+ openTelemetry.shutdown();
+ System.out.println("OpenTelemetry SDK shutdown complete.");
+ }
+ }
+
+ /**
+ * Initialize OpenTelemetry SDK with console and optional OTLP exporters.
+ */
+ private static OpenTelemetrySdk initializeOpenTelemetry(String otlpEndpoint) {
+ // Create resource with service name
+ Resource resource = Resource.getDefault()
+ .merge(Resource.create(Attributes.of(
+ AttributeKey.stringKey("service.name"), SERVICE_NAME
+ )));
+
+ // Create console exporter (logs metrics to stdout)
+ MetricReader consoleReader = PeriodicMetricReader.builder(LoggingMetricExporter.create())
+ .setInterval(Duration.ofSeconds(10))
+ .build();
+
+ // Define custom histogram buckets optimized for Pinecone latencies (in milliseconds)
+ // Fine granularity across the typical latency range (5ms - 500ms)
+ List{@code
+ * Pinecone client = new Pinecone.Builder("PINECONE_API_KEY")
+ * .withResponseMetadataListener(metadata -> {
+ * System.out.println("Operation: " + metadata.getOperationName());
+ * System.out.println("Server time: " + metadata.getServerDurationMs() + "ms");
+ * System.out.println("Total time: " + metadata.getClientDurationMs() + "ms");
+ * })
+ * .build();
+ *
+ * Index index = client.getIndexConnection("my-index");
+ * index.query(...); // Listener is automatically invoked
+ * }
+ *
+ * @param listener The listener to receive response metadata.
+ * @return This {@link Builder} instance for chaining method calls.
+ */
+ public Builder withResponseMetadataListener(ResponseMetadataListener listener) {
+ this.responseMetadataListener = listener;
+ return this;
+ }
+
/**
* Builds and returns a {@link Pinecone} instance configured with the provided API key, optional source tag,
* and OkHttpClient.
@@ -1726,6 +1764,9 @@ public Builder withTlsEnabled(boolean enableTls) {
public Pinecone build() {
PineconeConfig config = new PineconeConfig(apiKey, sourceTag, proxyConfig, customOkHttpClient);
config.setTLSEnabled(enableTls);
+ if (responseMetadataListener != null) {
+ config.setResponseMetadataListener(responseMetadataListener);
+ }
config.validate();
if (proxyConfig != null && customOkHttpClient != null) {
diff --git a/src/main/java/io/pinecone/configs/PineconeConfig.java b/src/main/java/io/pinecone/configs/PineconeConfig.java
index f435e0d0..a33313e0 100644
--- a/src/main/java/io/pinecone/configs/PineconeConfig.java
+++ b/src/main/java/io/pinecone/configs/PineconeConfig.java
@@ -55,6 +55,7 @@ public class PineconeConfig {
private OkHttpClient customOkHttpClient;
private ManagedChannel customManagedChannel;
private boolean enableTLS = true;
+ private ResponseMetadataListener responseMetadataListener;
/**
* Constructs a {@link PineconeConfig} instance with the specified API key.
@@ -248,6 +249,25 @@ public void setTLSEnabled(boolean enableTLS) {
this.enableTLS = enableTLS;
}
+ /**
+ * Returns the response metadata listener, or null if not configured.
+ *
+ * @return The response metadata listener for capturing timing metrics from data plane operations.
+ */
+ public ResponseMetadataListener getResponseMetadataListener() {
+ return responseMetadataListener;
+ }
+
+ /**
+ * Sets the response metadata listener for capturing timing metrics from data plane operations.
+ * The listener is invoked after each upsert, query, fetch, update, or delete operation completes.
+ *
+ * @param responseMetadataListener The listener to receive response metadata.
+ */
+ public void setResponseMetadataListener(ResponseMetadataListener responseMetadataListener) {
+ this.responseMetadataListener = responseMetadataListener;
+ }
+
private String buildUserAgent() {
String userAgent = String.format("lang=java; %s=%s", "pineconeClientVersion", pineconeClientVersion);
if (this.getSourceTag() != null && !this.getSourceTag().isEmpty()) {
diff --git a/src/main/java/io/pinecone/configs/PineconeConnection.java b/src/main/java/io/pinecone/configs/PineconeConnection.java
index 212bb27b..21685b7e 100644
--- a/src/main/java/io/pinecone/configs/PineconeConnection.java
+++ b/src/main/java/io/pinecone/configs/PineconeConnection.java
@@ -48,6 +48,7 @@ public class PineconeConnection implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(PineconeConnection.class);
private final PineconeConfig config;
+ private final String indexName;
final ManagedChannel channel;
/**
@@ -70,7 +71,22 @@ public class PineconeConnection implements AutoCloseable {
* @throws PineconeValidationException If index name or host is not provided for data plane operations.
*/
public PineconeConnection(PineconeConfig config) {
+ this(config, null);
+ }
+
+ /**
+ * Constructs a {@link PineconeConnection} instance with the specified {@link PineconeConfig} and index name.
+ * If a custom gRPC ManagedChannel is provided in the {@link PineconeConfig}, it will be used.
+ * Otherwise, a new gRPC ManagedChannel will be built using the host specified in the {@link PineconeConfig}.
+ *
+ *
+ *
+ * {@code
+ * Pinecone client = new Pinecone.Builder(apiKey)
+ * .withResponseMetadataListener(metadata -> {
+ * System.out.println("Operation: " + metadata.getOperationName());
+ * System.out.println("Server duration: " + metadata.getServerDurationMs() + "ms");
+ * System.out.println("Client duration: " + metadata.getClientDurationMs() + "ms");
+ * System.out.println("Network overhead: " + metadata.getNetworkOverheadMs() + "ms");
+ * })
+ * .build();
+ * }
+ */
+public class ResponseMetadata {
+
+ private final String operationName;
+ private final String indexName;
+ private final String namespace;
+ private final String serverAddress;
+ private final long clientDurationMs;
+ private final Long serverDurationMs;
+ private final String status;
+ private final String grpcStatusCode;
+ private final String errorType;
+
+ private ResponseMetadata(Builder builder) {
+ this.operationName = builder.operationName;
+ this.indexName = builder.indexName;
+ this.namespace = builder.namespace;
+ this.serverAddress = builder.serverAddress;
+ this.clientDurationMs = builder.clientDurationMs;
+ this.serverDurationMs = builder.serverDurationMs;
+ this.status = builder.status;
+ this.grpcStatusCode = builder.grpcStatusCode;
+ this.errorType = builder.errorType;
+ }
+
+ /**
+ * Returns the operation name (e.g., "upsert", "query", "fetch", "update", "delete").
+ * Corresponds to OTel attribute: db.operation.name
+ */
+ public String getOperationName() {
+ return operationName;
+ }
+
+ /**
+ * Returns the Pinecone index name.
+ * Corresponds to OTel attribute: pinecone.index_name
+ */
+ public String getIndexName() {
+ return indexName;
+ }
+
+ /**
+ * Returns the Pinecone namespace (empty string if default namespace).
+ * Corresponds to OTel attribute: db.namespace
+ */
+ public String getNamespace() {
+ return namespace;
+ }
+
+ /**
+ * Returns the server address/host.
+ * Corresponds to OTel attribute: server.address
+ */
+ public String getServerAddress() {
+ return serverAddress;
+ }
+
+ /**
+ * Returns the total client-side duration in milliseconds.
+ * Measured from request initiation to response completion.
+ * Corresponds to metric: db.client.operation.duration
+ */
+ public long getClientDurationMs() {
+ return clientDurationMs;
+ }
+
+ /**
+ * Returns the server processing duration in milliseconds, or null if the
+ * x-pinecone-response-duration-ms header was not present.
+ * Corresponds to metric: pinecone.server.processing.duration
+ */
+ public Long getServerDurationMs() {
+ return serverDurationMs;
+ }
+
+ /**
+ * Returns the computed network overhead in milliseconds (client duration minus server duration),
+ * or null if server duration is not available.
+ * This includes network latency, serialization, and deserialization time.
+ */
+ public Long getNetworkOverheadMs() {
+ if (serverDurationMs == null) {
+ return null;
+ }
+ return clientDurationMs - serverDurationMs;
+ }
+
+ /**
+ * Returns the operation status: "success" or "error".
+ * Corresponds to OTel attribute: status
+ */
+ public String getStatus() {
+ return status;
+ }
+
+ /**
+ * Returns the raw gRPC status code (e.g., "OK", "UNAVAILABLE", "DEADLINE_EXCEEDED").
+ * Corresponds to OTel attribute: db.response.status_code
+ */
+ public String getGrpcStatusCode() {
+ return grpcStatusCode;
+ }
+
+ /**
+ * Returns the error type category, or null if status is "success".
+ * Possible values: "validation", "connection", "server", "rate_limit", "timeout", "auth", "not_found"
+ * Corresponds to OTel attribute: error.type
+ */
+ public String getErrorType() {
+ return errorType;
+ }
+
+ /**
+ * Returns true if the operation was successful.
+ */
+ public boolean isSuccess() {
+ return "success".equals(status);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("ResponseMetadata{");
+ sb.append("operation=").append(operationName);
+ sb.append(", index=").append(indexName);
+ if (namespace != null && !namespace.isEmpty()) {
+ sb.append(", namespace=").append(namespace);
+ }
+ sb.append(", clientDurationMs=").append(clientDurationMs);
+ if (serverDurationMs != null) {
+ sb.append(", serverDurationMs=").append(serverDurationMs);
+ sb.append(", networkOverheadMs=").append(getNetworkOverheadMs());
+ }
+ sb.append(", status=").append(status);
+ if (errorType != null) {
+ sb.append(", errorType=").append(errorType);
+ }
+ sb.append("}");
+ return sb.toString();
+ }
+
+ public static class Builder {
+ private String operationName;
+ private String indexName;
+ private String namespace = "";
+ private String serverAddress;
+ private long clientDurationMs;
+ private Long serverDurationMs;
+ private String status = "success";
+ private String grpcStatusCode = "OK";
+ private String errorType;
+
+ public Builder operationName(String operationName) {
+ this.operationName = operationName;
+ return this;
+ }
+
+ public Builder indexName(String indexName) {
+ this.indexName = indexName;
+ return this;
+ }
+
+ public Builder namespace(String namespace) {
+ this.namespace = namespace != null ? namespace : "";
+ return this;
+ }
+
+ public Builder serverAddress(String serverAddress) {
+ this.serverAddress = serverAddress;
+ return this;
+ }
+
+ public Builder clientDurationMs(long clientDurationMs) {
+ this.clientDurationMs = clientDurationMs;
+ return this;
+ }
+
+ public Builder serverDurationMs(Long serverDurationMs) {
+ this.serverDurationMs = serverDurationMs;
+ return this;
+ }
+
+ public Builder status(String status) {
+ this.status = status;
+ return this;
+ }
+
+ public Builder grpcStatusCode(String grpcStatusCode) {
+ this.grpcStatusCode = grpcStatusCode;
+ return this;
+ }
+
+ public Builder errorType(String errorType) {
+ this.errorType = errorType;
+ return this;
+ }
+
+ public ResponseMetadata build() {
+ return new ResponseMetadata(this);
+ }
+ }
+}
+
diff --git a/src/main/java/io/pinecone/configs/ResponseMetadataInterceptor.java b/src/main/java/io/pinecone/configs/ResponseMetadataInterceptor.java
new file mode 100644
index 00000000..12774d14
--- /dev/null
+++ b/src/main/java/io/pinecone/configs/ResponseMetadataInterceptor.java
@@ -0,0 +1,207 @@
+package io.pinecone.configs;
+
+import io.grpc.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * gRPC ClientInterceptor that captures response metadata from Pinecone data plane operations.
+ * Extracts timing information from the x-pinecone-response-duration-ms trailing header.
+ */
+public class ResponseMetadataInterceptor implements ClientInterceptor {
+
+ private static final Logger logger = LoggerFactory.getLogger(ResponseMetadataInterceptor.class);
+
+ private static final Metadata.Key
+ *
+ *
+ * {@code
+ * Pinecone client = new Pinecone.Builder(apiKey)
+ * .withResponseMetadataListener(metadata -> {
+ * logger.info("Pinecone {} completed in {}ms (server: {}ms)",
+ * metadata.getOperationName(),
+ * metadata.getClientDurationMs(),
+ * metadata.getServerDurationMs());
+ * })
+ * .build();
+ * }
+ *
+ * {@code
+ * Meter meter = openTelemetry.getMeter("io.pinecone");
+ * DoubleHistogram clientDuration = meter.histogramBuilder("db.client.operation.duration")
+ * .setUnit("ms").build();
+ * DoubleHistogram serverDuration = meter.histogramBuilder("pinecone.server.processing.duration")
+ * .setUnit("ms").build();
+ * LongCounter operationCount = meter.counterBuilder("db.client.operation.count").build();
+ *
+ * Pinecone client = new Pinecone.Builder(apiKey)
+ * .withResponseMetadataListener(metadata -> {
+ * Attributes attrs = Attributes.builder()
+ * .put("db.system", "pinecone")
+ * .put("db.operation.name", metadata.getOperationName())
+ * .put("db.namespace", metadata.getNamespace())
+ * .put("status", metadata.getStatus())
+ * .build();
+ *
+ * clientDuration.record(metadata.getClientDurationMs(), attrs);
+ * if (metadata.getServerDurationMs() != null) {
+ * serverDuration.record(metadata.getServerDurationMs(), attrs);
+ * }
+ * operationCount.add(1, attrs);
+ * })
+ * .build();
+ * }
+ *
+ * {@code
+ * Pinecone client = new Pinecone.Builder(apiKey)
+ * .withResponseMetadataListener(metadata -> {
+ * Timer.builder("pinecone.client.duration")
+ * .tag("operation", metadata.getOperationName())
+ * .tag("status", metadata.getStatus())
+ * .register(meterRegistry)
+ * .record(metadata.getClientDurationMs(), TimeUnit.MILLISECONDS);
+ * })
+ * .build();
+ * }
+ *
+ * @see ResponseMetadata
+ */
+@FunctionalInterface
+public interface ResponseMetadataListener {
+
+ /**
+ * Called after each data plane operation completes.
+ *
+ *