Upcoming Webinar:

Getting Started with OpenObserve

July 16, 2026
11:00 AM ET

OpenTelemetry for Kotlin: Traces, Metrics & Logs with OpenObserve

Instrument Kotlin/JVM apps with the OpenTelemetry Java SDK and coroutine-safe context propagation — traces, metrics, and logs via OTLP to OpenObserve.

OpenTelemetry guide

Kotlin on the JVM does not have its own OpenTelemetry SDK — and that is good news. Kotlin services use the OpenTelemetry Java SDK and the Java agent directly, which means you inherit one of the most mature implementations in the entire project: traces, metrics, and logs are all stable, and the zero-code javaagent instruments more than a hundred libraries out of the box. What Kotlin adds is one sharp edge — coroutines break the thread-local context propagation the Java SDK relies on — and one small artifact, opentelemetry-extension-kotlin, that fixes it. This guide covers the full path: SDK setup with Gradle Kotlin DSL, coroutine-safe tracing, metrics, logs, and shipping everything to OpenObserve over OTLP.

If you are new to OpenTelemetry itself — the signals, the collector, OTLP — start with What is OpenTelemetry? A Complete Guide and come back here for the Kotlin specifics.

One scoping note up front: this guide is about server-side and CLI Kotlin on the JVM. Android has a separate opentelemetry-android project built on top of the Java SDK, with mobile-specific concerns (app lifecycle, disk buffering) that are out of scope here.

Why OpenTelemetry for Kotlin

  • Vendor-neutral instrumentation. Instrument once, export anywhere OTLP is accepted — no proprietary SDK in your codebase.
  • The full maturity of OpenTelemetry Java. The Java implementation is one of the most mature, with all three signals stable. Kotlin gets all of it for free, including the javaagent’s zero-code instrumentation of OkHttp, JDBC, gRPC, Kafka clients, and the engines under frameworks like Ktor and Spring.
  • First-party coroutine support. opentelemetry-extension-kotlin ships from the main opentelemetry-java repository as a stable artifact, so coroutine context propagation is a supported feature, not a community hack.

Signal stability for Kotlin (via the Java SDK)

As of mid-2026, the status Kotlin/JVM inherits from OpenTelemetry Java is:

SignalStatusNotes
TracesStableAPI and SDK stable; safe for production
MetricsStableAPI and SDK stable; OTLP metric exporters are GA
LogsStableLogs bridge API and SDK are stable; appender bridges (Logback, Log4j) live in the instrumentation repo and carry -alpha artifact versions even though they are widely used in production

The one Kotlin-specific caveat is not a signal at all: context propagation across coroutines is your responsibility unless you run the javaagent. We cover both paths below.

Prerequisites

  • JDK 8 or later (JDK 17+ recommended for new services) and Kotlin 1.8+ with kotlinx-coroutines if you use coroutines.
  • An OpenObserve instance. Either an OpenObserve Cloud account or a self-hosted instance. For local testing, a single container is enough:
docker run -d --name openobserve \
  -p 5080:5080 -p 5081:5081 \
  -e ZO_ROOT_USER_EMAIL="root@example.com" \
  -e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
  public.ecr.aws/zinclabs/openobserve:latest
  • Your OpenObserve credentials. OTLP ingestion uses HTTP Basic auth. Generate the token once:
echo -n 'root@example.com:Complexpass#123' | base64

This prints cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM= — the value you will pass in the Authorization: Basic header.

In OpenObserve Cloud, the ingestion page under Data Sources shows your organization-specific endpoint and a ready-made Basic auth token, so you rarely need to construct it by hand.

Zero-code instrumentation: the javaagent

Kotlin compiles to JVM bytecode, so the OpenTelemetry javaagent — the same one Java services use — works unmodified. It rewrites bytecode at class-load time to instrument HTTP clients and servers, JDBC, gRPC, Kafka, and dozens of other libraries, and it exports traces, metrics, and logs over OTLP with no code changes.

Download the agent and attach it at startup:

curl -L -O https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

java -javaagent:./opentelemetry-javaagent.jar -jar build/libs/checkout-service.jar

For local development with Gradle Kotlin DSL, wire it into your run task:

// build.gradle.kts
tasks.withType<JavaExec> {
    jvmArgs("-javaagent:${rootDir}/opentelemetry-javaagent.jar")
}

Two things make the agent especially attractive for Kotlin:

  1. Automatic coroutine context propagation. Since agent version 1.22.0, the agent instruments kotlinx.coroutines so the active trace context follows a coroutine across suspension points and thread switches. The single biggest Kotlin pitfall disappears.
  2. @WithSpan works on suspend functions. Add io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations and annotate a suspend fun — the agent understands the continuation-passing transform and ends the span when the coroutine actually completes, not when the function first suspends.

The agent is the right default for most Kotlin services. Use manual SDK setup (next section) when you cannot attach an agent, want to minimize startup overhead, or need full control over the pipeline. The two approaches also combine: run the agent for library instrumentation and use the API + opentelemetry-extension-kotlin for your own custom spans.

Manual instrumentation

Add the SDK with Gradle Kotlin DSL. Use the BOM so every OpenTelemetry artifact stays on the same version:

// build.gradle.kts
dependencies {
    implementation(platform("io.opentelemetry:opentelemetry-bom:1.61.0"))
    implementation("io.opentelemetry:opentelemetry-api")
    implementation("io.opentelemetry:opentelemetry-sdk")
    implementation("io.opentelemetry:opentelemetry-exporter-otlp")
    implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
    implementation("io.opentelemetry:opentelemetry-extension-kotlin")
}

One deviation from other language SDKs that trips people up immediately: the plain Java SDK builders do not read OTEL_* environment variables. Environment-based configuration lives in the opentelemetry-sdk-extension-autoconfigure module (included above), which you invoke explicitly. This is different from Go or Python, where the exporters read the environment on their own.

Initialize once at startup:

import io.opentelemetry.sdk.OpenTelemetrySdk
import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk

// Reads OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS,
// OTEL_SERVICE_NAME, etc. — see the OpenObserve section below.
val openTelemetry: OpenTelemetrySdk = AutoConfiguredOpenTelemetrySdk.builder()
    .setResultAsGlobal() // also registers GlobalOpenTelemetry
    .build()
    .openTelemetrySdk

// Flush pending telemetry on exit — skipping this drops the last batch.
Runtime.getRuntime().addShutdownHook(Thread { openTelemetry.close() })

Traces

Get a Tracer once (top-level val or injected singleton), then create spans around units of work. The Java API is pleasant from Kotlin, and the coroutine extension is where it becomes idiomatic:

import io.opentelemetry.api.trace.StatusCode
import io.opentelemetry.extension.kotlin.asContextElement
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext

val tracer = openTelemetry.getTracer("checkout-service")

suspend fun processPayment(orderId: String) {
    val span = tracer.spanBuilder("process-payment")
        .setAttribute("order.id", orderId)
        .setAttribute("payment.provider", "stripe")
        .startSpan()
    try {
        // asContextElement() carries the OTel context in the CoroutineContext,
        // so it survives suspension and thread switches. Everything inside —
        // including child coroutines — sees this span as current.
        withContext(span.asContextElement()) {
            chargeCard()   // spans started here become children of process-payment
            delay(40)      // resumes on any thread; context still intact
        }
    } catch (t: Throwable) {
        span.recordException(t)
        span.setStatus(StatusCode.ERROR, t.message ?: "payment failed")
        throw t
    } finally {
        span.end()
    }
}

suspend fun chargeCard() {
    val span = tracer.spanBuilder("charge-card").startSpan()
    try {
        withContext(span.asContextElement()) {
            delay(25) // simulate the provider call
        }
    } finally {
        span.end()
    }
}

The critical rule: never rely on Span.makeCurrent() across a suspension point. The Java SDK stores the current context in a ThreadLocal; coroutines hop threads at every suspension, so the context stays behind on the old thread. This code is subtly broken:

// WRONG — thread-local context does not survive suspension
suspend fun broken() {
    val span = tracer.spanBuilder("outer").startSpan()
    span.makeCurrent().use {
        delay(100) // may resume on a different thread
        // "inner" can parent to the wrong span — or to no span at all
        tracer.spanBuilder("inner").startSpan().end()
    }
    span.end()
}

The fix is always the same: convert the context to a coroutine context element with asContextElement() (available on both Context and Span) and install it via withContext or pass it to launch/async. Child coroutines inherit their parent’s CoroutineContext, so once the element is installed, structured concurrency propagates the trace for you:

import io.opentelemetry.context.Context
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope

suspend fun fanOut() = coroutineScope {
    // Both children inherit the current OTel context automatically
    // because it already rides in this scope's CoroutineContext.
    val stock = async { checkInventory() }
    val fraud = async { runFraudCheck() }
    stock.await() to fraud.await()
}

// Bridging FROM a non-coroutine call site INTO a coroutine:
fun handleRequest(scope: kotlinx.coroutines.CoroutineScope) {
    val otelContext = Context.current() // capture on the calling thread
    scope.launch(otelContext.asContextElement()) {
        processPayment("ord_8412")
    }
}

Note the snapshot semantics: asContextElement() captures the context at the moment you call it. Capture it on the thread where the span is current, not inside the coroutine you are launching.

Metrics

Metrics have no coroutine complications when you pass attributes explicitly — instruments are thread-safe and cheap to call. Create them once, not per request:

import io.opentelemetry.api.common.AttributeKey
import io.opentelemetry.api.common.Attributes

val meter = openTelemetry.getMeter("checkout-service")

val ordersProcessed = meter.counterBuilder("orders.processed")
    .setDescription("Number of orders processed")
    .setUnit("{order}")
    .build()

val paymentDuration = meter.histogramBuilder("payment.duration")
    .setDescription("Payment processing duration")
    .setUnit("ms")
    .build()

val providerKey: AttributeKey<String> = AttributeKey.stringKey("payment.provider")

fun recordOrder(elapsedMs: Double) {
    ordersProcessed.add(1, Attributes.of(providerKey, "stripe"))
    paymentDuration.record(elapsedMs)
}

For gauges over state you do not control (queue depth, pool size), use an async instrument with a callback:

meter.gaugeBuilder("queue.depth")
    .setUnit("{message}")
    .buildWithCallback { measurement ->
        measurement.record(paymentQueue.size.toDouble())
    }

The autoconfigured SDK exports metrics on a periodic reader (60 s default; set OTEL_METRIC_EXPORT_INTERVAL=15000 for 15 s).

Logs

The logs signal is stable on the JVM, and the intended pattern is a bridge: keep logging through your normal facade (almost always SLF4J + Logback in Kotlin services) and let an OpenTelemetry appender forward records into the OTLP pipeline. Logs written inside an active span automatically carry trace_id and span_id, so you can pivot from a slow trace straight to its log lines in OpenObserve.

Add the Logback appender (bridge artifacts live in the instrumentation repo and carry an -alpha version suffix — they are stable in practice but pin the version):

// build.gradle.kts
dependencies {
    implementation("io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0:2.28.0-alpha")
}

Register it in logback.xml alongside your console appender:

<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>%d %-5level %logger - %msg%n</pattern></encoder>
  </appender>
  <appender name="OTEL"
      class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender">
    <captureMdcAttributes>*</captureMdcAttributes>
  </appender>
  <root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="OTEL"/>
  </root>
</configuration>

Then hand the appender your SDK instance after initialization, and log as usual:

import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender
import org.slf4j.LoggerFactory

fun main() {
    // ... AutoConfiguredOpenTelemetrySdk setup from above ...
    OpenTelemetryAppender.install(openTelemetry)

    val log = LoggerFactory.getLogger("checkout-service")
    log.info("order processed order_id=ord_8412 amount_cents=4599")
    log.error("payment declined reason=insufficient_funds")
}

If you run the javaagent instead, skip all of this — the agent injects the appender automatically and captures Logback/Log4j output with no configuration.

Sending data to OpenObserve

OpenObserve ingests OTLP natively — no collector required in between, although running one is a fine architecture too. Two transports are supported:

  • OTLP/HTTP at <host>/api/<org>/v1/traces, /v1/metrics, and /v1/logs (port 5080 self-hosted). Recommended.
  • OTLP/gRPC on port 5081 (self-hosted), which additionally requires an organization header alongside Authorization.

Two Java-specific deviations from other SDKs to know before you set variables:

  1. Default protocol. The javaagent (2.x) defaults to http/protobuf, but the SDK autoconfigure module defaults to gRPC on port 4317. When you set up the SDK yourself, always set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf explicitly.
  2. Env vars need autoconfigure. As noted above, OTEL_* variables only take effect through the javaagent or AutoConfiguredOpenTelemetrySdk — plain SdkTracerProvider.builder() code ignores them.

OpenObserve Cloud:

export OTEL_SERVICE_NAME="checkout-service"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org-name>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<your-base64-token>"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"

java -javaagent:./opentelemetry-javaagent.jar -jar build/libs/checkout-service.jar

Self-hosted OpenObserve:

export OTEL_SERVICE_NAME="checkout-service"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=dev"

./gradlew run

The same variables drive both the javaagent and the autoconfigured SDK, so one configuration works for either instrumentation path.

Three rules that prevent 90% of setup failures:

  1. No trailing slash on the endpoint. The exporter appends /v1/traces (or /v1/metrics, /v1/logs) itself. .../api/default/ produces .../api/default//v1/traces and a 404.
  2. Percent-encode the space in the header value. OTEL_EXPORTER_OTLP_HEADERS uses a key=value,key=value format in which the value must be URL-encoded — hence Basic%20<token>, not Basic <token>.
  3. The organization is part of the URL path. /api/default targets the default organization; replace it with your org name in OpenObserve Cloud (visible on the ingestion/Data Sources page, which also shows the exact endpoint and token to copy).

If you would rather configure in code than via the environment — common when credentials come from a secret manager — every exporter builder accepts the values directly:

import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter

val exporter = OtlpHttpSpanExporter.builder()
    .setEndpoint("http://localhost:5080/api/default/v1/traces")
    .addHeader("Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=")
    .build()

Note that setEndpoint on the HTTP exporters takes the full signal path, whereas the environment variable takes the base URL — and in code the header uses a literal space, not %20. Environment variables win in fewer surprises; prefer them. For a broader tour of exporter configuration, see A Beginner’s Guide to OTLP Exporters.

Verifying data arrival in OpenObserve

Run the service, exercise a few requests, then open the OpenObserve UI (http://localhost:5080 self-hosted, or your cloud URL):

  1. Streams — you should see a default traces stream plus streams for your metrics and logs. If a stream exists, ingestion is working end to end.
  2. Traces — pick your service (checkout-service) on the Traces page and open a trace. You should see process-payment with charge-card nested under it — nested correctly even though both lived inside coroutines, which is your proof that context propagation is working.
  3. Logs — query the log stream and confirm records include trace_id and span_id fields; that confirms the Logback bridge is correlating correctly.
  4. Metrics — your orders.processed and payment.duration instruments appear as metric streams you can chart or attach to dashboards and alerts.

If nothing appears within ~30 seconds, jump to Troubleshooting below — and check the process’s stderr first, because the Java SDK logs export failures through java.util.logging.

Production tips

  • Keep the batch processors (never simple/synchronous export in production). The autoconfigured SDK and the javaagent already use BatchSpanProcessor and a batching log processor by default. Tune with OTEL_BSP_* variables only if you see queue-full drops.

  • Sample at the head, keep parents authoritative. For high-traffic services, ratio sampling with parent-based override is the standard starting point:

    export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
    export OTEL_TRACES_SAMPLER_ARG="0.1"   # keep 10% of root traces

    parentbased_ ensures a downstream Kotlin service honors the sampling decision made upstream, so you get complete traces instead of fragments.

  • Invest in resource attributes. service.name, service.version, and deployment.environment are the dimensions you will filter by in every OpenObserve query. Set them via OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES so operations can override them per environment without a rebuild.

  • Never launch trace-relevant work from GlobalScope or a bare new scope. A coroutine launched outside the structured-concurrency tree does not inherit the caller’s CoroutineContext, so the trace context is silently dropped. If detached work must stay trace-linked, capture Context.current().asContextElement() at the call site and pass it to launch explicitly.

  • Watch runBlocking boundaries. Entering runBlocking from a thread with an active span does not carry the context into the coroutine world by itself — pass runBlocking(Context.current().asContextElement()) { ... } when bridging blocking code into coroutines.

  • Close the SDK on shutdown. openTelemetry.close() (or shutdown() on each provider) flushes the final batches. Wire it into a JVM shutdown hook — short-lived jobs and CLIs lose their last spans without it.

  • Record errors on spans, not just in logs. span.recordException(t) plus span.setStatus(StatusCode.ERROR) is what makes failed requests filterable in the Traces UI. In Kotlin, be careful not to swallow CancellationException in the process — rethrow it after recording.

  • Pin the BOM and upgrade in lockstep. Import opentelemetry-bom for SDK artifacts, and keep javaagent/instrumentation versions (2.x) matched with the SDK line they target. Mixing releases is the most common source of confusing NoSuchMethodErrors after an upgrade.

  • Mind javaagent startup cost. The agent adds a few hundred milliseconds to several seconds of startup and some memory overhead. For latency-critical cold starts (serverless), prefer manual SDK setup with only the instrumentation you need.

Troubleshooting

Symptom: Failed to export spans ... Connection refused: localhost/127.0.0.1:4317. Fix: the SDK fell back to its default — gRPC to localhost:4317 — because OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_PROTOCOL is not set (or not visible to the process). Set the protocol to http/protobuf and the endpoint to your OpenObserve base URL. Remember the autoconfigure default is gRPC even though the javaagent’s is HTTP.

Symptom: exporter logs 404 Not Found and no data arrives. Fix: almost always a URL problem. Remove any trailing slash from OTEL_EXPORTER_OTLP_ENDPOINT, and confirm the path includes your organization (/api/default, not just the host). The final URL the exporter calls should look like http://localhost:5080/api/default/v1/traces.

Symptom: 401 Unauthorized on every export. Fix: regenerate the token with echo -n 'email:password' | base64 (the -n matters — a trailing newline corrupts the token), and make sure the header value in OTEL_EXPORTER_OTLP_HEADERS is Authorization=Basic%20<token> with the space percent-encoded. If you set headers in code via addHeader, use a literal space there instead.

Symptom: env vars are set, but the SDK behaves as if they do not exist. Fix: you are constructing providers by hand with SdkTracerProvider.builder(), which ignores the environment. Either initialize through AutoConfiguredOpenTelemetrySdk (add opentelemetry-sdk-extension-autoconfigure) or run under the javaagent — those are the two components that read OTEL_* variables.

Symptom: spans inside suspend functions appear as separate root traces, or parent to a random unrelated request. Fix: thread-local context leaked across coroutine boundaries. Replace makeCurrent() around suspension points with withContext(span.asContextElement()), pass Context.current().asContextElement() into launch/async when crossing from non-coroutine code, and check that any custom CoroutineDispatcher or thread pool is not being handed work without the context element. Under the javaagent (1.22.0+), this propagation is automatic — if you see it there, verify the agent is actually attached.

Symptom: the service exits cleanly but the last few spans and logs never arrive. Fix: the batch processors were never flushed. Call close() on the OpenTelemetrySdk (or shutdown()/forceFlush() on each provider) in a shutdown hook, with a timeout so a dead backend cannot hang your exit. This bites short-lived jobs hardest — for those, consider forceFlush() after each unit of work.

With the Java SDK wired up once at startup, opentelemetry-extension-kotlin keeping the context glued to your coroutines, and OTLP pointed at OpenObserve, a Kotlin service gets correlated traces, metrics, and logs in a single backend — searchable side by side, with no vendor-specific code anywhere in your application.

Frequently asked questions

Is there a separate OpenTelemetry SDK for Kotlin?

No. Kotlin on the JVM uses the OpenTelemetry Java SDK directly — the API, SDK, exporters, and the javaagent all work from Kotlin without any wrapper. The only Kotlin-specific artifact you need is opentelemetry-extension-kotlin, which makes the OpenTelemetry context propagate correctly across coroutine suspension points. Android is the exception: it has its own opentelemetry-android project built on top of the Java SDK.

Which OpenTelemetry signals are stable for Kotlin on the JVM?

All three. Because Kotlin/JVM uses the OpenTelemetry Java implementation, it inherits Java's stability status: traces, metrics, and logs are all stable and covered by the project's backward-compatibility guarantees. The opentelemetry-extension-kotlin artifact is released as a stable, non-alpha module alongside the SDK.

Does the OpenTelemetry javaagent work with Kotlin and coroutines?

Yes. The javaagent instruments the bytecode the Kotlin compiler produces, so libraries like OkHttp, Ktor's engine, JDBC drivers, and gRPC are traced automatically. Since agent version 1.22.0 it also instruments kotlinx.coroutines so the trace context follows a coroutine across suspension and thread switches without any code changes. If you set up the SDK manually instead of using the agent, you must add opentelemetry-extension-kotlin and use Context.asContextElement yourself.

What endpoint do I use to send OTLP data from Kotlin to OpenObserve?

Set OTEL_EXPORTER_OTLP_ENDPOINT to your OpenObserve base URL including the organization path, with no trailing slash. For OpenObserve Cloud that is https://api.openobserve.ai/api/your-org-name, and for a self-hosted instance it is http://localhost:5080/api/default. Also set OTEL_EXPORTER_OTLP_PROTOCOL to http/protobuf when configuring the SDK yourself, because the Java SDK's autoconfiguration defaults to gRPC. The exporter appends /v1/traces, /v1/metrics, and /v1/logs automatically.

How do I authenticate Kotlin OTLP exports to OpenObserve?

OpenObserve uses HTTP Basic authentication. Base64-encode email:password (or your ingestion token), then set OTEL_EXPORTER_OTLP_HEADERS to Authorization=Basic%20<base64-token> — the space after Basic must be percent-encoded as %20 in the environment variable. When configuring exporters in code with addHeader, use a literal space instead; the percent-encoding rule applies only to the environment variable format.

Why do my spans lose their parent inside Kotlin suspend functions?

The OpenTelemetry Java context is stored in a ThreadLocal, and a coroutine can resume on a different thread after every suspension point, leaving the context behind on the old thread. Wrap coroutine work in withContext(span.asContextElement()) from opentelemetry-extension-kotlin so the context travels with the coroutine instead of the thread, or run under the javaagent, which patches kotlinx.coroutines to do this automatically.

Keep reading

Ship your OpenTelemetry data somewhere better

OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.

Book a DemoBook a Demo