Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export default defineConfig({
{ text: "Windows Sandbox", link: "/guides/windows-sandbox" },
{ text: "Client Pool", link: "/guides/client-pool" },
{ text: "SDK Telemetry", link: "/guides/sdk-telemetry" },
{ text: "SDK Tracing (Pool Warmup)", link: "/guides/sdk-tracing" },
],
},
],
Expand Down
9 changes: 9 additions & 0 deletions docs/guides/client-pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,15 @@ validate a positive concurrency value and wait for every drained ID to receive a
best-effort kill attempt. The Go method is intentionally outside the
`SandboxPool` interface to preserve compatibility with third-party implementors.

### Tracing warmups (Kotlin)

The Kotlin SDK can emit an OpenTelemetry trace per warmup task (`pool.warmup`
root span plus `create` / `prepare` / `renew` / `commit` phases) when
`ConnectionConfig.enableTracing(true)` is set and an OpenTelemetry SDK +
exporter is on the classpath. `trace_id` / `span_id` are published to the
SLF4J MDC, so search your logs for a `sandbox_id` to find the warmup trace and
drill into phase durations. See [SDK Tracing (Pool Warmup)](/guides/sdk-tracing).

### Retiring an old pool namespace

The retirement procedure differs across SDKs because Go does not currently ship a
Expand Down
182 changes: 182 additions & 0 deletions docs/guides/sdk-tracing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
---
title: SDK Tracing (Pool Warmup)
description: How to enable OpenTelemetry tracing for the Kotlin SDK pool warmup path, what spans are produced, and how to query and drill down into warmup traces.
---

# SDK Tracing (Pool Warmup)

The Kotlin/Java SDK (`com.alibaba.opensandbox:sandbox`) can emit
[OpenTelemetry](https://opentelemetry.io/) traces for the client-side
`SandboxPool` warmup path. Each warmup task becomes one trace that covers the
full lifecycle — from the moment the reconcile loop submits the task until the
warmed sandbox is committed to the idle buffer — with per-phase spans so you
can find the actual warmup bottleneck.

Tracing is **opt-in** (`enableTracing(true)`) and **best-effort**: without an
OpenTelemetry SDK + exporter on the application classpath, all span calls are
no-ops and nothing is exported. Tracing never throws and never affects pool
behavior.

## Requirements

| Component | Minimum version |
|-----------|-----------------|
| Kotlin / Java SDK (`com.alibaba.opensandbox:sandbox`) | `1.0.19` |

Only the Kotlin SDK emits these traces today; the other language SDKs do not
yet support `enableTracing`.

## Enabling tracing

### 1. Add an OpenTelemetry SDK + exporter to your application

The SDK depends only on `opentelemetry-api` (no-op by default). To actually
export traces you bring your own SDK and exporter, for example OTLP over HTTP:

```kotlin
dependencies {
implementation("io.opentelemetry:opentelemetry-api:1.51.0")
implementation("io.opentelemetry:opentelemetry-sdk:1.51.0")
implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.51.0")
}
```

### 2. Configure a global `OpenTelemetry` instance

Warmup spans use the global instance (`GlobalOpenTelemetry`). Configure it at
application startup, e.g. with `OpenTelemetrySdk`:

```java
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;

SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.create(
OtlpGrpcSpanExporter.builder()
.setEndpoint("http://otel-collector:4317")
.build()))
.build();

OpenTelemetrySdk sdk = OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.build();

GlobalOpenTelemetry.set(sdk);
```

::: tip Propagators
`OpenTelemetrySdk.builder()` defaults to **noop propagators**. If you want the
SDK to inject the W3C `traceparent` header into lifecycle requests (so the
lifecycle server can join the same trace once it supports tracing), configure
W3C propagation explicitly:

```java
.setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
```
:::

::: tip Sampling
To keep trace volume bounded, use a sampling strategy such as
`parentbased_traceidratio(0.1)` on the `SdkTracerProvider`. Trace-id-ratio
sampling keeps client and server spans consistent for the same warmup.
:::

### 3. Turn tracing on for the pool

```java
ConnectionConfig config = ConnectionConfig.builder()
.enableTracing(true)
.build();

SandboxPool pool = SandboxPool.builder()
.poolName("demo-pool")
.maxIdle(3)
.stateStore(new InMemoryPoolStateStore())
.connectionConfig(config)
.creationSpec(PoolCreationSpec.builder().image("ubuntu:22.04").build())
.build();
```

That is all. No environment variables are involved; `enableTracing` defaults
to `false`.

## What is traced

Each warmup task produces **one trace** with a root span and four sequential
phase spans (siblings under the root, so each phase duration stands alone for
comparison):

| Span name | Covers |
|-----------|--------|
| `pool.warmup` (root) | Task submission → sandbox committed to idle. Backdated to submission time, so the queue wait before the first phase is visible as the gap before the first child span |
| `pool.warmup.create` | Sandbox create API call, endpoint resolution, and readiness wait |
| `pool.warmup.prepare` | The configured `warmupSandboxPreparer` (user init script / setup work) |
| `pool.warmup.renew` | TTL renewal right before committing the sandbox |
| `pool.warmup.commit` | Primary-lock renewal + `putIdle` against the state store (runs on the pool scheduler thread) |

Root span attributes (these are your drill-down dimensions):

| Attribute | Value |
|-----------|-------|
| `pool.name` | Pool name |
| `pool.owner` | Pool owner id |
| `pool.run.generation` | Pool run generation |
| `sandbox.id` | Sandbox id (success only) |
| `sandbox.image` | Creation image (success only) |
| `result` | `success` or `failure` |

Failures are recorded with `recordException` on the root span plus
`result=failure`; the `pool.warmup.commit` span is not emitted for failed
warmups.

## Correlating logs to traces

While a warmup trace is in progress, the pool publishes the trace ids to the
SLF4J [MDC](https://www.slf4j.org/api/org/slf4j/MDC.html):

| MDC key | Value |
|---------|-------|
| `trace_id` | Current trace id |
| `span_id` | Current span id |

MDC requires a real SLF4J provider (logback, log4j2, ...). Add the keys to
your log pattern once, and every pool log line carries the trace context:

```xml
<pattern>%d %-5level [%thread] %logger{36} trace_id=%X{trace_id} span_id=%X{span_id} - %msg%n</pattern>
```

## Querying traces

The trace id is random, so a warmup trace cannot be looked up "by pool name"
directly. The reliable paths are:

1. **Log correlation (recommended).** The pool already logs `pool_name` and
`sandbox_id` on its warmup lines (e.g. `Pool warmup sandbox entered idle`).
Search your logs for a `sandbox_id` — the matching log lines carry
`trace_id`, which you can open directly in your trace backend.
2. **Attribute query in the trace backend.** Filter spans by time window and
attribute, e.g. TraceQL `{ span.pool.name = "demo-pool" }` (Grafana Tempo),
or Jaeger tag search on `pool.name=...`. Backends that derive metrics from
spans (Tempo metrics, Datadog span analytics) let you look at
`pool.warmup` duration percentiles per `pool.name` first, then drill into
slow traces.
3. **Trace-id-ratio sampling.** With sampled traces, `trace_id` in logs and
the backend are consistent for the same warmup.

### Bottleneck drill-down

```
pool.warmup root duration (p50/p95/p99) per pool.name
└─ phase spans: pool.warmup.create / prepare / renew / commit
└─ single trace: root start gap = queue wait, then each phase duration
```

| Symptom | Likely cause |
|---------|--------------|
| Long gap before the first child span | Warmup tasks queued — `warmupConcurrency` too low, or the executor is busy with cleanup kills |
| `pool.warmup.create` slow | Lifecycle server slow (image pull / execd startup) or readiness polling takes long |
| `pool.warmup.prepare` slow | Your `warmupSandboxPreparer` work is the bottleneck |
| `pool.warmup.renew` / `pool.warmup.commit` slow | State store (e.g. Redis) round-trips |
9 changes: 9 additions & 0 deletions docs/sdks/kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,14 @@ poolManager.destroy(
- Use `warmupSandboxPreparer(...)` if you need to prepare a sandbox after warmup readiness succeeds and before it is put into the idle pool.
:::

::: tip Observing warmup performance
To trace the warmup path, enable `ConnectionConfig.builder().enableTracing(true)` and add an
OpenTelemetry SDK + exporter to your application. Each warmup becomes one trace
(`pool.warmup` root span plus `create` / `prepare` / `renew` / `commit` phases) with
`trace_id` / `span_id` published to the SLF4J MDC, so you can look up a sandbox's
warmup by searching logs for its `sandbox_id`. See [SDK Tracing (Pool Warmup)](/guides/sdk-tracing).
:::

::: tip Distributed Deployment
For distributed deployment, use the optional `com.alibaba.opensandbox:sandbox-pool-redis` module or provide a custom `PoolStateStore` implementation. The Redis module accepts a caller-managed Jedis client, so your application keeps ownership of Redis connection configuration and lifecycle. Nodes sharing the same pool namespace must use the same sandbox creation and warmup definition; use a new `poolName` or namespace when changing that definition. Configure `primaryLockTtl` greater than `warmupReadyTimeout` plus expected warmup preparer time and buffer, otherwise leadership may expire while a node is creating idle sandboxes.

Expand Down Expand Up @@ -329,6 +337,7 @@ The `ConnectionConfig` class manages API server connection settings.
| `retryPolicy` | Automatic retry policy for non-streaming requests (see [Automatic retries](#_2-automatic-retries)) | Enabled (`RetryPolicy()`) | - |
| `useServerProxy` | Use sandbox server as proxy for execd/endpoint requests (e.g. when client cannot reach the sandbox directly) | `false` | - |
| `disableMetrics` | Disable SDK create-latency telemetry (see [SDK Telemetry](/guides/sdk-telemetry)) | `false` | `OPENSANDBOX_DISABLE_METRICS` |
| `enableTracing` | Enable OpenTelemetry tracing for pool warmup (see [SDK Tracing](/guides/sdk-tracing)) | `false` | - |

```java
// 1. Basic configuration
Expand Down
6 changes: 6 additions & 0 deletions sdks/sandbox/kotlin/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ kotlin = "2.2.21"
kotlinx-serialization = "1.9.0"
okhttp = "4.12.0"
slf4j = "2.0.9"
opentelemetry = "1.51.0"
jedis = "5.2.0"
junit = "5.10.1"
mockk = "1.13.8"
Expand All @@ -43,6 +44,11 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa

# Logging
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.18" }

# Tracing (API only; no-op when no OpenTelemetry SDK is on the classpath)
opentelemetry-api = { module = "io.opentelemetry:opentelemetry-api", version.ref = "opentelemetry" }
opentelemetry-sdk-testing = { module = "io.opentelemetry:opentelemetry-sdk-testing", version.ref = "opentelemetry" }

# Redis
jedis = { module = "redis.clients:jedis", version.ref = "jedis" }
Expand Down
1 change: 1 addition & 0 deletions sdks/sandbox/kotlin/sandbox-bom/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ dependencies {
api(libs.okhttp)
api(libs.okhttp.logging)
api(libs.slf4j.api)
api(libs.opentelemetry.api)
}
}
3 changes: 3 additions & 0 deletions sdks/sandbox/kotlin/sandbox/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ dependencies {

implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.opentelemetry.api)
compileOnly(libs.bundles.serialization)

testImplementation(libs.bundles.testing)
testImplementation(libs.bundles.serialization)
testImplementation(libs.opentelemetry.sdk.testing)
testRuntimeOnly(libs.logback.classic)
testRuntimeOnly(libs.junit.platform.launcher)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ package com.alibaba.opensandbox.sandbox
import com.alibaba.opensandbox.sandbox.config.ConnectionConfig
import com.alibaba.opensandbox.sandbox.domain.models.execd.SECURE_ACCESS_HEADER
import com.alibaba.opensandbox.sandbox.transport.RetryInterceptor
import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.context.Context
import io.opentelemetry.context.propagation.TextMapPropagator
import io.opentelemetry.context.propagation.TextMapSetter
import okhttp3.ConnectionPool
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
Expand All @@ -44,12 +49,21 @@ class HttpClientProvider(
private val connectionPoolOwnedBySdk: Boolean = config.connectionPool == null

private val baseBuilder: OkHttpClient.Builder
get() =
OkHttpClient.Builder()
.connectionPool(connectionPool)
.addInterceptor(UserAgentInterceptor(config.userAgent))
.addInterceptor(ExtraHeadersInterceptor(config.headers))
.addInterceptor(ClientIpInterceptor { ClientIpDetector.clientIp() })
get() {
val builder =
OkHttpClient.Builder()
.connectionPool(connectionPool)
.addInterceptor(UserAgentInterceptor(config.userAgent))
.addInterceptor(ExtraHeadersInterceptor(config.headers))
.addInterceptor(ClientIpInterceptor { ClientIpDetector.clientIp() })
if (config.enableTracing) {
// Propagate the active trace context (W3C traceparent) so the
// lifecycle server can join the same trace. No-op when there
// is no active span in the current context.
builder.addInterceptor(TraceContextInterceptor(GlobalOpenTelemetry.getPropagators().textMapPropagator))
}
return builder
}

// 1. Explicit lazy definition to allow checking initialization status
private val httpClientLazy =
Expand Down Expand Up @@ -202,6 +216,27 @@ class HttpClientProvider(
}
}

/**
* Injects the W3C `traceparent` / `tracestate` headers of the current
* OpenTelemetry context into every request. When no span is active the
* propagator injects nothing and the request passes through unchanged.
*/
private class TraceContextInterceptor(
private val propagators: TextMapPropagator,
) : Interceptor {
private val setter =
TextMapSetter<Headers.Builder> { carrier: Headers.Builder?, key, value ->
carrier?.set(key, value)
}

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val headers = request.headers.newBuilder()
propagators.inject(Context.current(), headers, setter)
return chain.proceed(request.newBuilder().headers(headers.build()).build())
}
}

// --- Cleanup ---

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ class ConnectionConfig private constructor(
* Also honored via the `OPENSANDBOX_DISABLE_METRICS=1` environment variable.
*/
val disableMetrics: Boolean = false,
/**
* Enable OpenTelemetry tracing for the client-side sandbox pool warmup path.
*
* Off by default. When enabled, each pool warmup creates an OpenTelemetry
* trace (`pool.warmup` root span plus per-phase spans) and the active
* trace context is propagated to lifecycle requests via the W3C
* `traceparent` header. Tracing is best-effort: without an
* OpenTelemetry SDK + exporter on the classpath, all span calls are
* no-ops and nothing is exported.
*/
val enableTracing: Boolean = false,
/**
* Retry policy applied to non-streaming requests. Enabled by default; pass
* [RetryPolicy.disabled] to disable SDK-policy retries and fall back to
Expand Down Expand Up @@ -88,6 +99,7 @@ class ConnectionConfig private constructor(
endpointCacheSize = this.endpointCacheSize,
endpointCacheDisabled = this.endpointCacheDisabled,
disableMetrics = this.disableMetrics,
enableTracing = this.enableTracing,
retryPolicy = this.retryPolicy,
)

Expand Down Expand Up @@ -188,6 +200,7 @@ class ConnectionConfig private constructor(
private var endpointCacheSize: Int = 1024
private var endpointCacheDisabled: Boolean = false
private var disableMetrics: Boolean = false
private var enableTracing: Boolean = false
private var retryPolicy: RetryPolicy = RetryPolicy()

/**
Expand Down Expand Up @@ -228,6 +241,17 @@ class ConnectionConfig private constructor(
return this
}

/**
* Enable OpenTelemetry tracing for the client-side sandbox pool warmup path.
*
* Off by default; pass `true` to opt in. Tracing is best-effort and no-ops
* unless an OpenTelemetry SDK + exporter is on the classpath.
*/
fun enableTracing(enable: Boolean = true): Builder {
this.enableTracing = enable
return this
}

/**
* Set the API key used for authentication.
*
Expand Down Expand Up @@ -383,6 +407,7 @@ class ConnectionConfig private constructor(
endpointCacheSize = endpointCacheSize,
endpointCacheDisabled = endpointCacheDisabled,
disableMetrics = disableMetrics,
enableTracing = enableTracing,
retryPolicy = retryPolicy,
)
}
Expand Down
Loading
Loading