You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/20 12:15:55 UTC

(skywalking) branch master updated: Store OTLP traces natively and bring the TraceQL module closer to the Tempo API (#14096)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking.git


The following commit(s) were added to refs/heads/master by this push:
     new 3af86a1ec4 Store OTLP traces natively and bring the TraceQL module closer to the Tempo API (#14096)
3af86a1ec4 is described below

commit 3af86a1ec4c455e2fddb879ae51ad661112d9c78
Author: Wan Kai <wa...@foxmail.com>
AuthorDate: Sun Sep 20 20:15:49 2026 +0800

    Store OTLP traces natively and bring the TraceQL module closer to the Tempo API (#14096)
    
    **Native OTLP trace storage.** `receiver-otel` gains `otlpTraceStorage: otlp`, which keeps each span as the `ResourceSpans` it arrived in (`otlp_span`, BanyanDB group `otlpTrace`) with service, span-name and peer catalogs, and the TraceQL module gains an `/otlp` datasource (`enableDatasourceOTLP`) that serves those spans to Grafana Tempo without any Zipkin conversion. `otlp` is the default, so a stock `application.yml` stores OTLP traces natively and no longer needs the Zipkin modules [...]
    
    **Tempo API alignment on every datasource.** Search results list only the spans that matched in each span set (capped at `spss`) and fill `serviceStats`; `span.http.status_code` is filterable; the tag endpoints honour `scope` and the per-scope `limit`, list an `intrinsic` scope with what each datasource can filter, accept Tempo's unscoped `.key` names, and narrow tag-name and tag-value lookups by `q` from a sample of the newest 50 matching traces so Grafana's dependent dropdowns work. [...]
    
    **Strict TraceQL (#14093).** Unsupported TraceQL is refused with `400` instead of dropping the predicate and answering with unfiltered traces: syntax errors (including `||`, which Grafana emits for multi-select), unsupported operators, negation, attribute-existence checks, multiple spansets, unknown intrinsics and values, and conditions a datasource cannot filter (`kind` on Zipkin and SkyWalking, `resource.instance` on Zipkin, `resource.remote.service` and `status = unset` on SkyWalki [...]
    
    **Tests.** Unit tests for the parser, the three span matchers, the assembler and converters, the handler helpers, `OTLPSpanForward` and the JDBC DAO. New e2e cases `otlp-traces/native/{banyandb,es,mysql}` and `otlp-virtual-genai/native`; `traceql/{zipkin,skywalking}` and `storage` expectations updated.
---
 .github/workflows/skywalking.yaml                  |   8 +
 docs/en/api/traceql-service.md                     | 159 ++++++--
 docs/en/banyandb/ttl.md                            |   1 +
 docs/en/changes/changes.md                         |   3 +
 docs/en/setup/backend/admin-api/status.md          |   6 +
 docs/en/setup/backend/configuration-vocabulary.md  |  26 ++
 docs/en/setup/backend/otlp-trace.md                |  99 ++++-
 docs/en/setup/backend/storages/banyandb.md         |  19 +
 docs/en/setup/backend/ui-grafana.md                |  11 +-
 .../listener/AIEvaluationSpanListener.java         |  57 +++
 .../analyzer/genai/listener/GenAISpanListener.java |  21 +-
 .../analyzer/genai/service/GenAIMeterAnalyzer.java |  85 +++-
 .../genai/service/IGenAIMeterAnalyzerService.java  |   9 +
 oap-server/server-admin/status/pom.xml             |   9 +
 .../server/admin/status/DebuggingHTTPHandler.java  |  87 ++++
 .../server/admin/status/StatusModuleProvider.java  |   3 +
 .../trace/otlp/DebuggingOTLPQueryTraceRsp.java}    |  28 +-
 .../trace/otlp/DebuggingOTLPQueryTracesRsp.java}   |  28 +-
 .../core/analysis/manual/searchtag/TagType.java    |   3 +-
 .../core/otlp/OTLPServiceRelationTraffic.java      | 125 ++++++
 .../server/core/otlp/OTLPServiceSpanTraffic.java   | 126 ++++++
 .../oap/server/core/otlp/OTLPServiceTraffic.java   | 116 ++++++
 .../oap/server/core/otlp/OTLPSpanRecord.java       | 275 +++++++++++++
 .../otlp/dispatcher/OTLPServiceDispatcher.java}    |  32 +-
 .../dispatcher/OTLPServiceRelationDispatcher.java  |  35 ++
 .../otlp/dispatcher/OTLPServiceSpanDispatcher.java |  35 ++
 .../otlp/dispatcher/OTLPSpanRecordDispatcher.java  |  47 +++
 .../oap/server/core/otlp/source/OTLPService.java   |  53 +++
 .../core/otlp/source/OTLPServiceRelation.java      |  61 +++
 .../server/core/otlp/source/OTLPServiceSpan.java   |  58 +++
 .../oap/server/core/otlp/source/OTLPSpan.java      | 116 ++++++
 .../server/core/query/OTLPTraceQueryService.java   | 112 +++++
 .../oap/server/core/query/TTLStatusQuery.java      |   5 +-
 .../core/query/input/OTLPTraceQueryCondition.java  |  72 ++++
 .../oap/server/core/source/DefaultScopeDefine.java |   4 +
 .../oap/server/core/storage/StorageModule.java     |   2 +
 .../server/core/storage/annotation/BanyanDB.java   |   1 +
 .../core/storage/query/IOTLPTraceQueryDAO.java     |  98 +++++
 .../oap/server/core/storage/ttl/RecordsTTL.java    |   2 +
 .../oap/server/core/storage/ttl/TTLDefinition.java |   2 +
 .../oap/server/core/trace/OTLPSpanReader.java      |  25 ++
 .../oap/server/core/trace/SpanListener.java        |  20 +
 .../oap/server/core/trace/SpanListenerManager.java |  27 +-
 .../server-core/src/main/proto/SpanWrapper.proto   |   2 +
 .../oap/query/tempo/grammar/TraceQLLexer.g4        |   1 +
 .../oap/query/tempo/grammar/TraceQLParser.g4       |   6 +-
 .../oap/query/traceql/TraceQLConfig.java           |  12 +
 .../oap/query/traceql/TraceQLProvider.java         |  14 +-
 .../query/traceql/converter/OTLPSpanMatcher.java   | 143 +++++++
 .../traceql/converter/OTLPTraceAssembler.java      | 454 +++++++++++++++++++++
 .../traceql/converter/SkyWalkingOTLPConverter.java | 152 ++++++-
 .../traceql/converter/SkyWalkingSpanMatcher.java   | 111 +++++
 .../traceql/converter/ZipkinOTLPConverter.java     | 136 +++++-
 .../query/traceql/converter/ZipkinSpanMatcher.java |  75 ++++
 .../oap/query/traceql/entity/SearchResponse.java   |  16 +
 .../traceql/handler/OTLPTraceQLApiHandler.java     | 414 +++++++++++++++++++
 .../handler/SkyWalkingTraceQLApiHandler.java       | 356 +++++++++-------
 .../query/traceql/handler/TraceQLApiHandler.java   | 356 ++++++++++++++--
 .../traceql/handler/ZipkinTraceQLApiHandler.java   | 289 +++++++------
 .../oap/query/traceql/rt/TraceQLQueryParams.java   |  30 +-
 .../oap/query/traceql/rt/TraceQLQueryParser.java   |  39 +-
 .../oap/query/traceql/rt/TraceQLQueryVisitor.java  | 148 ++++---
 .../query/tempo/parser/TraceQLQueryParserTest.java | 117 +++++-
 .../traceql/converter/OTLPSpanMatcherTest.java     | 146 +++++++
 .../traceql/converter/OTLPTraceAssemblerTest.java  | 268 ++++++++++++
 .../converter/SkyWalkingOTLPConverterTest.java     | 117 ++++++
 .../converter/SkyWalkingSpanMatcherTest.java       |  95 +++++
 .../traceql/converter/ZipkinOTLPConverterTest.java | 119 ++++++
 .../traceql/converter/ZipkinSpanMatcherTest.java   |  74 ++++
 .../traceql/handler/TraceQLApiHandlerTest.java     | 182 +++++++++
 .../receiver/otel/OtelMetricReceiverConfig.java    |  58 +++
 .../receiver/otel/OtelMetricReceiverProvider.java  |  19 +-
 .../server/receiver/otel/otlp/OTLPSpanForward.java | 278 +++++++++++++
 .../receiver/otel/otlp/OTLPSpanReaderImpl.java     |  46 ++-
 .../otel/otlp/OpenTelemetryTraceHTTPHandler.java   |  14 +-
 .../otel/otlp/OpenTelemetryTraceHandler.java       | 205 +++++++---
 .../receiver/otel/otlp/OTLPSpanForwardTest.java    | 221 ++++++++++
 .../server-receiver-plugin/receiver-proto/pom.xml  |   5 +
 .../oap/server/receiver/otel/otlp/OTLPValues.java  | 102 +++++
 .../proto/opentelemetry/proto/trace/v1/trace.proto |  73 +++-
 .../src/main/resources/application.yml             |  16 +
 .../server-starter/src/main/resources/bydb.yml     |  22 +
 .../banyandb/BanyanDBConfigDumpExtension.java      |   1 +
 .../plugin/banyandb/BanyanDBConfigLoader.java      |  11 +
 .../plugin/banyandb/BanyanDBStorageConfig.java     |  10 +
 .../plugin/banyandb/BanyanDBStorageProvider.java   |   3 +
 .../plugin/banyandb/BanyanDBTTLStatusQuery.java    |  16 +-
 .../storage/plugin/banyandb/MetadataRegistry.java  |   9 +
 .../banyandb/trace/BanyanDBOTLPTraceQueryDAO.java  | 211 ++++++++++
 .../src/test/resources/bydb.yml                    |  22 +
 .../StorageModuleElasticsearchProvider.java        |   4 +
 .../query/otlp/OTLPTraceQueryEsDAO.java            | 297 ++++++++++++++
 .../plugin/jdbc/common/JDBCStorageProvider.java    |   6 +
 .../jdbc/common/dao/JDBCOTLPTraceQueryDAO.java     | 313 ++++++++++++++
 .../jdbc/common/dao/JDBCOTLPTraceQueryDAOTest.java | 201 +++++++++
 test/e2e-v2/cases/otlp-traces/docker-compose.yml   |   1 +
 .../otlp-traces/native/banyandb/docker-compose.yml |  67 +++
 .../cases/otlp-traces/native/banyandb/e2e.yaml     |  49 +++
 .../docker-compose-demo.yml}                       |  35 +-
 .../cases/otlp-traces/native/es/docker-compose.yml |  76 ++++
 test/e2e-v2/cases/otlp-traces/native/es/e2e.yaml   |  49 +++
 .../native/expected/buildinfo.yml}                 |  30 +-
 .../native/expected/search-tags-v2.yml}            |  47 +--
 .../native}/expected/search-traces-by-duration.yml |   8 +-
 .../expected/search-traces-by-kind-internal.yml}   |  13 +-
 .../native}/expected/search-traces-by-service.yml  |  11 +-
 .../native}/expected/search-traces-complex.yml     |  13 +-
 .../native/expected/tag-values-service.yml}        |  30 +-
 .../native/expected/tag-values-span-name.yml}      |  28 +-
 .../native/expected/tag-values-status.yml}         |  32 +-
 .../native/expected/trace-by-id-json.yml           |  73 ++++
 .../expected/trace-by-id-protobuf-header.yml}      |  24 +-
 .../otlp-traces/native/mysql/docker-compose.yml    |  78 ++++
 .../e2e-v2/cases/otlp-traces/native/mysql/e2e.yaml |  49 +++
 .../cases/otlp-traces/native/traceql-cases.yaml    |  75 ++++
 .../cases/otlp-virtual-genai/docker-compose.yml    |   1 +
 .../{ => native}/docker-compose.yml                |  34 +-
 .../cases/otlp-virtual-genai/native/e2e.yaml       |  45 ++
 test/e2e-v2/cases/storage/expected/config-dump.yml |   4 +
 .../cases/storage/expected/ttl-config-banyandb.yml |   2 +
 test/e2e-v2/cases/storage/expected/ttl-config.yml  |   2 +
 .../expected/search-traces-by-duration.yml         |   6 +
 .../expected/search-traces-by-service.yml          |   6 +
 .../skywalking/expected/search-traces-complex.yml  |   6 +
 .../cases/traceql/skywalking/traceql-cases.yaml    |   2 +-
 .../zipkin/expected/search-traces-by-duration.yml  |   6 +
 .../zipkin/expected/search-traces-by-service.yml   |   6 +
 .../zipkin/expected/search-traces-complex.yml      |   6 +
 .../e2e-v2/cases/traceql/zipkin/traceql-cases.yaml |   2 +-
 129 files changed, 8243 insertions(+), 819 deletions(-)

diff --git a/.github/workflows/skywalking.yaml b/.github/workflows/skywalking.yaml
index bc059041e7..c55545b61a 100644
--- a/.github/workflows/skywalking.yaml
+++ b/.github/workflows/skywalking.yaml
@@ -700,6 +700,8 @@ jobs:
             config: test/e2e-v2/cases/virtual-genai/e2e.yaml
           - name: OTLP Virtual GenAI
             config: test/e2e-v2/cases/otlp-virtual-genai/e2e.yaml
+          - name: OTLP Virtual GenAI Native
+            config: test/e2e-v2/cases/otlp-virtual-genai/native/e2e.yaml
           - name: Zipkin Virtual GenAI
             config: test/e2e-v2/cases/zipkin-virtual-genai/e2e.yaml
           - name: iOS Monitoring
@@ -766,6 +768,12 @@ jobs:
 
           - name: OTLP Trace
             config: test/e2e-v2/cases/otlp-traces/e2e.yaml
+          - name: OTLP Trace Native BanyanDB
+            config: test/e2e-v2/cases/otlp-traces/native/banyandb/e2e.yaml
+          - name: OTLP Trace Native ES
+            config: test/e2e-v2/cases/otlp-traces/native/es/e2e.yaml
+          - name: OTLP Trace Native MySQL
+            config: test/e2e-v2/cases/otlp-traces/native/mysql/e2e.yaml
 
           - name: UI Management BanyanDB
             config: test/e2e-v2/cases/ui-management/banyandb/e2e.yaml
diff --git a/docs/en/api/traceql-service.md b/docs/en/api/traceql-service.md
index b9a5a4c5fa..022fa3c972 100644
--- a/docs/en/api/traceql-service.md
+++ b/docs/en/api/traceql-service.md
@@ -3,9 +3,9 @@ TraceQL ([Trace Query Language](https://grafana.com/docs/tempo/latest/traceql/))
 TraceQL Service exposes Tempo Querying HTTP APIs including TraceQL expression system and OpenTelemetry Protocol (OTLP) trace format.
 Third-party systems or visualization platforms that already support Tempo and TraceQL (such as Grafana), could obtain traces through TraceQL Service.
 
-SkyWalking supports two types of traces: SkyWalking native traces and Zipkin-compatible traces. The TraceQL Service converts both trace formats to [Tempo's format](https://github.com/grafana/tempo/blob/main/pkg/tempopb/tempo.proto) 
+SkyWalking supports three types of traces: SkyWalking native traces, Zipkin-compatible traces, and natively stored OTLP traces. The TraceQL Service converts the first two formats to [Tempo's format](https://github.com/grafana/tempo/blob/main/pkg/tempopb/tempo.proto) 
 to provide compatibility with Grafana Tempo and TraceQL queries. Since the trace detail part of Tempo's format (`Trace` message) reuses [OTLP Trace](https://opentelemetry.io/docs/reference/specification/protocol/) definitions, 
-the conversion descriptions below refer to OTLP field names (e.g., span kind, status code).
+the conversion descriptions below refer to OTLP field names (e.g., span kind, status code). Natively stored OTLP traces need no conversion, see [OTLP Native Trace](#otlp-native-trace).
 
 > **Note**: SkyWalking native trace support in TraceQL is based on the [Query Traces V2 API](https://skywalking.apache.org/docs/main/next/en/api/query-protocol/#trace-v2) (`queryTraces` / `hasQueryTracesV2Support`).
 > Currently, only **BanyanDB** storage implements this API. Other storage backends (e.g. Elasticsearch, MySQL, PostgreSQL) do not support it.
@@ -21,23 +21,33 @@ The expression supported by TraceQL is composed of the following parts (expressi
 - [x] **Attribute Filtering**: Filter by span attributes (scoped and unscoped)
   - [x] `.service.name` - Service name (unscoped)
   - [x] `resource.service.name` - Service name (scoped)
-  - [x] `span.<tags>` - Any span tags with scope (e.g., `span.http.method`, `span.http.status_code`, etc.)
-- [x] **Intrinsic Fields**: Built-in trace fields
-  - [x] `duration` - Trace duration with comparison operators (supports units: us/µs, ms, s, m, h. Default unit: microseconds. Minimum: microseconds, Maximum: hours)
+  - [x] `span.<key>`, `resource.<key>` - Attribute equality in that scope (e.g., `span.http.method`, `span.http.status_code`). The `/otlp` datasource matches the scope; the Zipkin and SkyWalking tag indexes have no scopes, so there the key matches in any scope
+  - [x] `.<key>` - Unscoped attribute, either scope
+- [x] **Intrinsic Fields**: Built-in trace fields, plain or with Tempo's `span:` prefix (`span:kind`)
+  - [x] `duration` - Span duration with comparison operators (supports units: us/µs, ms, s, m, h. Default unit: microseconds. Minimum: microseconds, Maximum: hours)
   - [x] `name` - Span name
-  - [x] `status` - Span status
-  - [ ] `kind` - Span kind
+  - [x] `status` - Span status: `error`, `ok`, `unset`; the Zipkin datasource filters `error` only, the SkyWalking datasource `error` and `ok`
+  - [x] `kind` - Span kind: `unspecified`, `internal`, `server`, `client`, `producer`, `consumer`; OTLP datasource only
 - [x] **Comparison Operators**: 
   - [x] `=` - Equals
-  - [x] `>` - Greater than (for duration)
+  - [x] `>` - Greater than (for duration); evaluated at microsecond precision, the smallest unit accepted, so `duration > 100ms` selects spans of at least 100.001 ms
   - [x] `>=` - Greater than or equal (for duration)
   - [x] `<` - Less than (for duration)
   - [x] `<=` - Less than or equal (for duration)
+  - [ ] `!=` - Not equals
+- [ ] **Negation** (`!`) and **attribute existence** (`{ span.foo }` without a comparison)
+- [ ] **OR inside a spanset** (`{ a || b }`), which Grafana's query builder emits for a multi-select value
 - [ ] **Spanset Logical Operations**: AND/OR between spansets (e.g., `{...} AND {...}`)
 - [ ] **Pipeline Operations**: `|` operator for aggregations
 - [ ] **Aggregate Functions**: count(), avg(), max(), min(), sum()
 - [ ] **Regular Expression**: `=~` and `!~` operators
 
+An expression that uses anything unsupported, a syntax error, an attribute written without a scope or a leading dot
+(`http.method` instead of `.http.method` or `span.http.method`), or a value a datasource cannot filter (`kind` on the
+Zipkin and SkyWalking datasources, `resource.instance` or `name` without a service on the SkyWalking datasource) is
+refused with `400` and a message naming the construct. It is never answered with the predicate dropped, because a
+full page of traces under a filter that excludes them reads as data.
+
 Here are some typical TraceQL expressions used in SkyWalking:
 ```traceql
 # Query traces by service name
@@ -68,7 +78,7 @@ Here are some typical TraceQL expressions used in SkyWalking:
 ```
 ```traceql
 # Query traces by status
-{status="STATUS_CODE_OK"}
+{status=ok}                 # error, ok or unset; the Zipkin datasource filters error only
 ```
 
 **Duration Units**:
@@ -153,9 +163,9 @@ GET /api/v2/search/tags
 
 | Parameter | Definition                     | Optional |
 |-----------|--------------------------------|----------|
-| q         | TraceQL query to filter       | yes      |
-| scope     | Scope to filter tags          | yes      |
-| limit     | Maximum number of tags        | yes      |
+| q         | TraceQL filter. With one, the `resource` and `span` names come from the spans of the newest 50 matching traces, so only keys those spans carry are offered | yes      |
+| scope     | Scope to filter tags: `resource`, `span`, `intrinsic`; `event`, `link` and `instrumentation` are accepted and empty | yes      |
+| limit     | Maximum number of tags per scope | yes      |
 | start     | Start timestamp (seconds)     | yes      |
 | end       | End timestamp (seconds)       | yes      |
 
@@ -222,8 +232,8 @@ GET /api/v2/search/tag/{tagName}/values
 
 | Parameter | Definition                     | Optional |
 |-----------|--------------------------------|----------|
-| tagName   | Name of the tag               | no       |
-| q         | TraceQL filter query          | yes      |
+| tagName   | `resource.<key>`, `span.<key>`, Tempo's unscoped `.<key>`, or an intrinsic such as `status` or `span:status` | no       |
+| q         | TraceQL filter. A filter that names only a service is answered from the per-service catalogs (`name`, `resource.remote.service`, `resource.instance`); any narrower filter is answered from the spans of the newest 50 matching traces. `status` and `kind` always list their full enum | yes      |
 | limit     | Maximum number of values      | yes      |
 | start     | Start timestamp (seconds)     | yes      |
 | end       | End timestamp (seconds)       | yes      |
@@ -259,13 +269,17 @@ GET /api/search
 | Parameter   | Definition                                      | Optional       |
 |-------------|-------------------------------------------------|----------------|
 | q           | TraceQL query                                   | yes            |
-| tags        | Deprecated tag query format                     | yes            |
+| tags        | Tempo's deprecated logfmt form, `key=value` pairs separated by spaces, used when `q` is absent: `service.name` and `span.name` (or `name`) select the service and the span, every other key is an attribute equality. A pair that is not `key=value` is refused with 400 | yes            |
 | minDuration | Minimum trace duration                          | yes            |
 | maxDuration | Maximum trace duration                          | yes            |
 | limit       | Maximum number of traces to return. Default: 20 | yes            |
 | start       | Start timestamp (seconds)                       | yes            |
 | end         | End timestamp (seconds)                         | yes            |
-| spss        | Spans per span set                              | not supported  |
+| spss        | Spans listed per span set. Default: 3           | yes            |
+
+Every datasource lists only the spans that matched the query in each `spanSets` entry, re-evaluated in memory against
+the same conditions the storage applied, and caps the list at `spss`; `matched` counts every matching span. Each trace
+also carries `serviceStats`, the span and error counts per service over the whole trace.
 
 **Example**:
 ```text
@@ -408,7 +422,7 @@ GET /api/traces/{traceId}
 GET /zipkin/api/traces/abc123def456
 ```
 
-**Response (JSON)**:
+**Response (JSON)**:YY
 See Query Trace by ID (v2) below for response format.
 
 ### Query Trace by ID (v2)
@@ -672,7 +686,14 @@ When `Accept: application/protobuf` header is set, the response will be in OpenT
 
 ## Zipkin Trace Conversion
 
-When using the Zipkin backend, the following conversions are applied:
+When using the Zipkin backend, the following conversions are applied.
+
+TraceQL filters map onto the Zipkin query: `resource.service.name` and `resource.remote.service` onto the local and
+remote service, `name` onto the span name, `duration` onto the span duration, `status = error` onto the `error` tag,
+and `span.<key>` (including `span.http.status_code`) onto tag equality. Zipkin spans have no filterable kind and no
+`ok` status, so the `intrinsic` scope of `/api/v2/search/tags` lists `name`, `status` and `duration`, and `status`
+has the single value `error`. A `q` narrower than a service name makes the tag endpoints sample the newest 50 matching
+traces and answer from their matching spans.
 
 ### Span Kind Mapping
 Zipkin span kinds are mapped to OTLP span kinds:
@@ -684,7 +705,11 @@ Zipkin span kinds are mapped to OTLP span kinds:
 | `PRODUCER`       | `SPAN_KIND_PRODUCER`    |
 | `CONSUMER`       | `SPAN_KIND_CONSUMER`    |
 | (absent)         | `SPAN_KIND_UNSPECIFIED` |
-| (other)          | `SPAN_KIND_INTERNAL`    |
+
+Zipkin has no `INTERNAL` kind, so those four values and "absent" are the complete set. An OTLP `SPAN_KIND_INTERNAL` span that
+was stored through the OTLP receiver's Zipkin conversion has no Zipkin kind and reads back as `SPAN_KIND_UNSPECIFIED`;
+the receiver keeps a `span.kind=internal` tag on it, which is searchable as an ordinary tag but is not used for the
+`kind` intrinsic. Natively stored OTLP spans keep their original kind, see [OTLP Native Trace](#otlp-native-trace).
 
 ### Status Mapping
 Zipkin tags are used to derive the OTLP span status in the following priority order:
@@ -720,7 +745,16 @@ version: "0.1.0"
 
 ## SkyWalking Native Trace Conversion
 
-When using the SkyWalking native backend, the following conversions are applied:
+When using the SkyWalking native backend, the following conversions are applied.
+
+TraceQL filters map onto the trace query: `resource.service.name` onto the service, `resource.instance` onto the
+instance, `name` onto the endpoint, `duration` onto the span duration, `status = error` and `status = ok` onto the
+trace state, and `span.<key>` (including `span.http.status_code`) onto tag equality. The span type is not a query
+condition, so the `intrinsic` scope of `/api/v2/search/tags` lists `name`, `status` and `duration`; `kind`,
+`status = unset` and `resource.remote.service` are refused with 400, and `resource.instance` and `name` need
+`resource.service.name` because instance and endpoint ids are scoped by service. A `q` narrower
+than a service name makes the tag endpoints sample the newest 50 matching traces and answer from their matching
+spans; the `span` names offered are cut down to the `searchableTracesTags`, the only tags the trace query can filter.
 
 ### Trace ID Encoding
 
@@ -781,32 +815,95 @@ name: "skywalking-tracer"
 version: "0.1.0"
 ```
 
+## OTLP Native Trace
+The `/otlp` datasource serves the spans that `receiver-otel` stored natively (`otlpTraceStorage: otlp`, see
+[OpenTelemetry Trace Format](../setup/backend/otlp-trace.md#native-otlp-mode)). Nothing is converted: a trace is
+returned as the `ResourceSpans` the exporters sent, regrouped into one entry per resource and one `ScopeSpans` per
+instrumentation scope, with the spans of each scope ordered by start time. Attribute types, events, links, status
+messages and the instrumentation scope are exactly what the SDK exported.
+
+The datasource is available on every storage. `coldStage=true` on `/api/search` and `/api/v2/traces/{traceId}`
+reads the BanyanDB cold stage instead of the hot and warm stages and needs `start` and `end`.
+
+### TraceQL Field Mapping
+| TraceQL field                     | Stored column / index                                                                        |
+|-----------------------------------|----------------------------------------------------------------------------------------------|
+| `resource.service.name`           | `service_name`, the service name resolved from the resource attributes                       |
+| `resource.service.instance.id`    | `service_instance`                                                                           |
+| `resource.remote.service`         | `peer_service`, the `peer.service` span attribute                                            |
+| `span.otel.scope.name`            | `scope_name`, the instrumentation scope name                                                 |
+| `span.peer.service`               | `peer_service`                                                                               |
+| `span.service.instance.id`        | `service_instance`                                                                           |
+| `span.http.status_code`           | The `http.status_code` attribute, as an ordinary tag condition                               |
+| `resource.<key>`                  | The `resource.<key>=<value>` entry of the tag index, every resource attribute is indexed      |
+| `span.<key>`                      | The `span.<key>=<value>` entry of the tag index, every span attribute is indexed             |
+| `.<key>`                          | Either entry, as TraceQL defines an unscoped attribute                                        |
+| `name`, `span:name`               | `name`, the span name after the core module's endpoint naming rules (grouping and length limit), as SkyWalking endpoint names are stored |
+| `kind`, `span:kind`               | `kind`. Accepted values: `unspecified`, `internal`, `server`, `client`, `producer`, `consumer` |
+| `status`, `span:status`           | `status_code`. Accepted values: `error`, `ok`, `unset`                                        |
+| `duration`, `span:duration`       | `duration`, in nanoseconds; TraceQL durations such as `100ms` are converted                  |
+
+Tempo's other scoped intrinsics, `trace:*`, `event:*`, `link:*` and `instrumentation:*`, have no column to filter on
+and are rejected with 400 rather than ignored.
+
+### Search Results
+As on every datasource, a span set holds only the spans that matched, capped at `spss`, and `serviceStats` counts the
+spans and errors per service. Every returned trace has at least one matching span: when the storage matched a trace
+but no single span of it satisfies every condition of the query, the trace is left out rather than listed with spans
+the query excluded. The listed attributes are `service.name`, `span.kind` and the keys configured by
+`otlpTracesListResultTags`, each with its OTLP type.
+
+### Tag Names and Values
+`/api/v2/search/tags` returns `service.name`, `service.instance.id` and `remote.service` under the `resource` scope,
+the keys configured by `receiver-otel.default.otlpTraceSearchableTags` plus `otel.scope.name` under the `span` scope,
+and `name`, `status`, `kind`, `duration` and their `span:` spellings under `intrinsic`. `scope` narrows the answer to
+one scope (`event`, `link` and `instrumentation` are accepted and empty), `limit` caps the number of names per scope,
+and the v1 `/api/search/tags` lists the same names without scopes. `service.name` values come from the service catalog, `name`
+values from the span-name catalog of the service given in `q`, `remote.service` values from the peer catalog, and the
+other span keys from the tag autocomplete index; `limit` caps every value list. A `q` narrower than a service name, for
+instance `{ resource.service.name = "frontend" && kind = client }`, switches both endpoints to a sample: the newest 50
+matching traces are read and the names or values are taken from the spans in them that match the filter, which is
+how Grafana's dependent dropdowns narrow down. Every attribute is indexed, so every key such a sample offers is
+filterable. `status` and `kind` are exempt: their dropdowns always list the full enum, so `error` stays selectable
+when the newest traces happen to have none.
+
+### Trace by ID
+`/api/v2/traces/{traceId}` takes the 32-character lowercase hex trace id, the `traceID` returned by `/api/search`,
+and returns `application/protobuf` or `application/json` according to the `Accept` header. Trace, span and parent
+span ids are hex in the JSON form. `start` and `end` are optional; when given, Elasticsearch and the JDBC storages
+read only the spans that started in that range, and BanyanDB the day segments the range touches, since its trace
+model resolves a trace id per segment. Pass the search window with Grafana's time shift rather than a narrow one.
+
 ## Configuration
 
 ### Enabling Backends
-TraceQL Service supports two backends that can be independently enabled or disabled:
+TraceQL Service supports three backends that can be independently enabled or disabled:
 
-| Configuration Field        | Env Variable                             | Default | Description                         |
-|----------------------------|------------------------------------------|---------|-------------------------------------|
-| `enableDatasourceZipkin`   | `SW_TRACEQL_ENABLE_DATASOURCE_ZIPKIN`    | `false` | Enable Zipkin-compatible backend    |
-| `enableDatasourceSkywalking` | `SW_TRACEQL_ENABLE_DATASOURCE_SKYWALKING` | `false` | Enable SkyWalking native backend    |
+| Configuration Field          | Env Variable                              | Default | Description                                                                                                        |
+|------------------------------|-------------------------------------------|---------|--------------------------------------------------------------------------------------------------------------------|
+| `enableDatasourceZipkin`     | `SW_TRACEQL_ENABLE_DATASOURCE_ZIPKIN`     | `false` | Enable Zipkin-compatible backend                                                                                   |
+| `enableDatasourceSkywalking` | `SW_TRACEQL_ENABLE_DATASOURCE_SKYWALKING` | `false` | Enable SkyWalking native backend                                                                                   |
+| `enableDatasourceOTLP`       | `SW_TRACEQL_ENABLE_DATASOURCE_OTLP`       | `false` | Enable the OTLP backend, serving spans stored natively by `receiver-otel` with `otlpTraceStorage: otlp`          |
 
 ### Context Path
 Each backend is served under a separate context path:
 
-| Configuration Field           | Env Variable                              | Default       | Description                        |
-|-------------------------------|-------------------------------------------|---------------|------------------------------------|
-| `restContextPathZipkin`       | `SW_TRACEQL_REST_CONTEXT_PATH_ZIPKIN`     | `/zipkin`     | Context path for Zipkin backend    |
+| Configuration Field           | Env Variable                              | Default       | Description                         |
+|-------------------------------|-------------------------------------------|---------------|-------------------------------------|
+| `restContextPathZipkin`       | `SW_TRACEQL_REST_CONTEXT_PATH_ZIPKIN`     | `/zipkin`     | Context path for Zipkin backend     |
 | `restContextPathSkywalking`   | `SW_TRACEQL_REST_CONTEXT_PATH_SKYWALKING` | `/skywalking` | Context path for SkyWalking backend |
+| `restContextPathOTLP`         | `SW_TRACEQL_REST_CONTEXT_PATH_OTLP`       | `/otlp`       | Context path for OTLP backend       |
 
 ### Traces List Result Tags
 The tags included in the search result spans can be configured. Only listed tags are returned in `Search Traces` responses.
-`service.name` and `span.kind` are always included regardless of this setting.
+`service.name`, `span.kind` and `status` (`error`, `ok` or `unset`, so a trace list can show failures without opening
+each trace) are always included regardless of this setting.
 
 | Configuration Field              | Env Variable                                     | Default                                                                              |
 |----------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------|
 | `zipkinTracesListResultTags`     | `SW_TRACEQL_ZIPKIN_TRACES_LIST_RESULT_TAGS`      | `http.method,error`                                                                  |
 | `skywalkingTracesListResultTags` | `SW_TRACEQL_SKYWALKING_TRACES_LIST_RESULT_TAGS`  | `http.method,http.status_code,rpc.status_code,db.type,db.instance,mq.queue,mq.topic,mq.broker` |
+| `otlpTracesListResultTags`       | `SW_TRACEQL_OTLP_TRACES_LIST_RESULT_TAGS`        | `http.request.method,http.response.status_code`                                      |
 
 ### Other Settings
 
@@ -827,13 +924,16 @@ traceQL:
     restPort: ${SW_TRACEQL_REST_PORT:3200}
     enableDatasourceZipkin: ${SW_TRACEQL_ENABLE_DATASOURCE_ZIPKIN:true}
     enableDatasourceSkywalking: ${SW_TRACEQL_ENABLE_DATASOURCE_SKYWALKING:true}
+    enableDatasourceOTLP: ${SW_TRACEQL_ENABLE_DATASOURCE_OTLP:true}
     restContextPathZipkin: ${SW_TRACEQL_REST_CONTEXT_PATH_ZIPKIN:/zipkin}
     restContextPathSkywalking: ${SW_TRACEQL_REST_CONTEXT_PATH_SKYWALKING:/skywalking}
+    restContextPathOTLP: ${SW_TRACEQL_REST_CONTEXT_PATH_OTLP:/otlp}
     restIdleTimeOut: ${SW_TRACEQL_REST_IDLE_TIMEOUT:30000}
     restAcceptQueueSize: ${SW_TRACEQL_REST_QUEUE_SIZE:0}
     lookback: ${SW_TRACEQL_LOOKBACK:86400000}
     zipkinTracesListResultTags: ${SW_TRACEQL_ZIPKIN_TRACES_LIST_RESULT_TAGS:http.method,error}
     skywalkingTracesListResultTags: ${SW_TRACEQL_SKYWALKING_TRACES_LIST_RESULT_TAGS:http.method,http.status_code,rpc.status_code,db.type,db.instance,mq.queue,mq.topic,mq.broker}
+    otlpTracesListResultTags: ${SW_TRACEQL_OTLP_TRACES_LIST_RESULT_TAGS:http.request.method,http.response.status_code}
 ```
 
 ## Integration with Grafana
@@ -843,6 +943,7 @@ For more details, refer to [Use Grafana As The UI](../setup/backend/ui-grafana.m
 ## Limitations
 - Pipeline operations (`|` operator) are not supported
 - Aggregate functions (count, avg, max, min, sum) are not supported
+- TraceQL metrics, `/api/metrics/query_range` and `/api/metrics/query`, are not supported by any datasource; both answer `501 Not Implemented` with an error body
 - Regular expression matching (`=~`, `!~`) is not implemented
 - Spanset logical operations (AND, OR between spansets) are not supported
 - Event and link scopes are not supported
diff --git a/docs/en/banyandb/ttl.md b/docs/en/banyandb/ttl.md
index 818eb4136b..6ea179f01a 100644
--- a/docs/en/banyandb/ttl.md
+++ b/docs/en/banyandb/ttl.md
@@ -32,6 +32,7 @@ Warm and Cold are disabled by default. The values listed for warm/cold apply if
 | records                | 3   | 7        | 30       |
 | trace                  | 3   | 7        | 30       |
 | zipkinTrace            | 3   | 7        | 30       |
+| otlpTrace              | 3   | 7        | 30       |
 | recordsLog             | 3   | 7        | 30       |
 | recordsBrowserErrorLog | 3   | 7        | 30       |
 | metricsMinute          | 7   | 15       | 60       |
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index 175c54975b..d39bbd827c 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -24,6 +24,9 @@
 * Fix the runtime-rule sync tearing down every bundled MAL and LAL rule file on its first pass after each boot: `StaticRuleLoader` seeds a bundled rule with a RUNNING state, but the gone-keys pass recognised a boot-seeded entry only by a null state, so every shipped rule file without an operator row was torn down and reloaded from the bundle one after another. A MAL teardown waits a persistence round for the metric drain, and for that round the OpenTelemetry receiver held no converter fo [...]
 * Open every port that takes requests only after the boot is complete. A new `ModuleProvider.notifyBootCompleted` hook runs once every module's `notifyAfterCompleted` has returned, and the core's gRPC and HTTP servers, the sharing server, the Zipkin, AWS Firehose, eBPF, Envoy metrics and Zabbix receivers, the admin server and the PromQL, LogQL, TraceQL and Zipkin query servers bind there. Before, the core opened 11800 and 12800 in its own `notifyAfterCompleted`, ahead of the modules star [...]
 * Fix the Elasticsearch storage filtering events by a layer name against the numeric `layer` column, which made every event query carrying a layer condition fail with `number_format_exception` while the query was built, before any document was read. It is what Horizon UI's per-service events popout sends, so that popout failed for every service; on an empty index as well. The condition value is now resolved to its layer value, as the JDBC and BanyanDB storages already do.
+* Store OTLP traces natively. `receiver-otel` gains `otlpTraceStorage: otlp`, which keeps each span as the `ResourceSpans` it arrived in (`otlp_span`, BanyanDB group `otlpTrace`) with service, span-name and peer catalogs, and the TraceQL module gains an `/otlp` datasource (`enableDatasourceOTLP`) that serves those spans to Grafana Tempo without any Zipkin conversion. `otlp` is the new default; `otlpTraceStorage: zipkin` keeps the previous conversion to Zipkin v2 through receiver-zipkin,  [...]
+* Bring the TraceQL module closer to the Tempo API. Every datasource lists only the spans that matched a search in each span set, capped at `spss`, fills `serviceStats`, filters `span.http.status_code`, honours `scope` and the per-scope `limit` on the tag endpoints, whose `intrinsic` scope lists what each datasource can filter, accepts Tempo's unscoped `.key` tag names on the tag-value endpoints, and answers a tag-name or tag-value lookup filtered by `q` from a sample of the newest 50 ma [...]
+* Refuse unsupported TraceQL with `400` instead of dropping the predicate and answering with unfiltered traces: syntax errors (`||` inside a spanset, regex, an attribute without a scope or leading dot), `!=` and other unsupported operators, negation, attribute-existence checks, multiple spansets, unknown intrinsics and values, and conditions a datasource cannot filter (`kind` on Zipkin and SkyWalking, `resource.instance` on Zipkin, `resource.remote.service` and `status = unset` on SkyWal [...]
 
 #### UI
 * Add a Virtual GenAI evaluation-record page and evaluation-score chart in Horizon UI, so operators can inspect evaluation result, level, reason, judge model, timestamp, trace linkage, and the `gen_ai_model_evaluation_score_ppm` trend for evaluated records.
diff --git a/docs/en/setup/backend/admin-api/status.md b/docs/en/setup/backend/admin-api/status.md
index 232c67a517..4391390e92 100644
--- a/docs/en/setup/backend/admin-api/status.md
+++ b/docs/en/setup/backend/admin-api/status.md
@@ -382,12 +382,14 @@ metrics.day.cold=-1
 records.normal=3
 records.trace=10
 records.zipkinTrace=3
+records.otlpTrace=3
 records.log=3
 records.browserErrorLog=3
 # Cold data, '-1' represents no cold stage data.
 records.normal.cold=-1
 records.trace.cold=30
 records.zipkinTrace.cold=-1
+records.otlpTrace.cold=-1
 records.log.cold=-1
 records.browserErrorLog.cold=-1
 ```
@@ -413,11 +415,13 @@ curl -X GET "http://oap:17128/status/config/ttl" \
     "normal": 3,
     "trace": 10,
     "zipkinTrace": 3,
+    "otlpTrace": 3,
     "log": 3,
     "browserErrorLog": 3,
     "coldNormal": -1,
     "coldTrace": 30,
     "coldZipkinTrace": -1,
+    "coldOtlpTrace": -1,
     "coldLog": -1,
     "coldBrowserErrorLog": -1
   }
@@ -448,6 +452,8 @@ why a query is slow or returning unexpected data.
 | `/debugging/query/trace/queryTrace` | Trace detail. |
 | `/debugging/query/zipkin/api/v2/traces` | Zipkin compat brief. |
 | `/debugging/query/zipkin/api/v2/trace` | Zipkin compat detail. |
+| `/debugging/query/otlp/api/search` | Natively stored OTLP trace search, the TraceQL `/otlp/api/search` query (`q`, `start`, `end`, `limit`, `coldStage`). |
+| `/debugging/query/otlp/api/v2/trace` | Natively stored OTLP trace detail by `traceId`, the TraceQL `/otlp/api/v2/traces/{traceId}` query. |
 | `/debugging/query/topology/getGlobalTopology` | Global topology debug. |
 | `/debugging/query/topology/getServicesTopology` | Per-service topology debug. |
 | `/debugging/query/topology/getServiceInstanceTopology` | Per-instance topology debug. |
diff --git a/docs/en/setup/backend/configuration-vocabulary.md b/docs/en/setup/backend/configuration-vocabulary.md
index 48597a77dc..9dbf19e555 100644
--- a/docs/en/setup/backend/configuration-vocabulary.md
+++ b/docs/en/setup/backend/configuration-vocabulary.md
@@ -209,6 +209,10 @@ It divided into several modules, each of which has its own settings. The followi
 | receiver-otel           | default       | A receiver for analyzing metrics data from OpenTelemetry.                                                                                                                | -                                                                                                                                                                                                                                                                                     [...]
 | -                       | -             | enabledHandlers                                                                                                                                                          | Enabled handlers for otel.                                                                                                                                                                                                                                                            [...]
 | -                       | -             | enabledOtelMetricsRules                                                                                                                                                  | Enabled metric rules for OTLP handler.                                                                                                                                                                                                                                                [...]
+| -                       | -             | otlpTraceStorage | Where the `otlp-traces` handler stores spans. `otlp` stores the native OTLP span in `otlp_span`, served by the TraceQL `/otlp` datasource; `zipkin` converts to Zipkin v2 and hands them to receiver-zipkin. | SW_OTEL_TRACE_STORAGE | otlp |
+| -                       | -             | otlpTraceSearchableTags | `otlp` mode only. Attribute keys offered by tag autocomplete. Every resource and span attribute is indexed for equality search regardless of this list. | SW_OTEL_TRACE_SEARCHABLE_TAG_KEYS | http.request.method,http.response.status_code,url.path,rpc.method,rpc.grpc.status_code,db.system.name,db.namespace,messaging.system,messaging.destination.name,peer.service |
+| -                       | -             | otlpTraceSampleRate | `otlp` mode only. Head sampling by trace id, precision 1/10000. 10000 keeps every trace. | SW_OTEL_TRACE_SAMPLE_RATE | 10000 |
+| -                       | -             | otlpTraceMaxSpansPerSecond | `otlp` mode only. Spans per second accepted before dropping. 0 means no limit. | SW_OTEL_TRACE_MAX_SPANS_PER_SECOND | 0 |
 | receiver-zipkin         | default       | A receiver for Zipkin traces.                                                                                                                                            | -                                                                                                                                                                                                                                                                                     [...]
 | -                       | -             | sampleRate                                                                                                                                                               | The sample rate precision is 1/10000, should be between 0 and 10000                                                                                                                                                                                                                   [...]
 | -                       | -             | maxSpansPerSecond                                                                                                                                                        | The maximum spans to be collected per second. 0 means no limit. Spans exceeding this threshold will be dropped.                                                                                                                                                                       [...]
@@ -296,8 +300,10 @@ It divided into several modules, each of which has its own settings. The followi
 | -                       | -             | restPort                                                                                                                                                                 | Binding port of RESTful services. Default is Grafana Tempo's native port.                                                                                                                                                                                                             [...]
 | -                       | -             | enableDatasourceZipkin                                                                                                                                                   | Enables the Zipkin data source for the TraceQL API. When `true`, the Zipkin-based handler is mounted at `restContextPathZipkin`. Requires the `receiver-zipkin` module to be enabled.                                                                                                 [...]
 | -                       | -             | enableDatasourceSkywalking                                                                                                                                               | Enables the SkyWalking native data source for the TraceQL API. When `true`, the SkyWalking-native handler is mounted at `restContextPathSkywalking`.                                                                                                                                  [...]
+| -                       | -             | enableDatasourceOTLP | Enables the OTLP data source for the TraceQL API. Serves spans stored natively by `receiver-otel` (`otlpTraceStorage: otlp`) as the OTLP they arrived as. | SW_TRACEQL_ENABLE_DATASOURCE_OTLP | false |
 | -                       | -             | restContextPathZipkin                                                                                                                                                    | Web context path for the Zipkin data source endpoint.                                                                                                                                                                                                                                 [...]
 | -                       | -             | restContextPathSkywalking                                                                                                                                                | Web context path for the SkyWalking native data source endpoint.                                                                                                                                                                                                                      [...]
+| -                       | -             | restContextPathOTLP | Web context path for the OTLP data source endpoints. | SW_TRACEQL_REST_CONTEXT_PATH_OTLP | /otlp |
 | -                       | -             | restIdleTimeOut                                                                                                                                                          | Connector idle timeout of RESTful services (in milliseconds).                                                                                                                                                                                                                         [...]
 | -                       | -             | restAcceptQueueSize                                                                                                                                                      | ServerSocketChannel Backlog of RESTful services.                                                                                                                                                                                                                                      [...]
 | -                       | -             | restSSLEnabled        | Activates TLS for the TraceQL HTTP server (reloaded on rotation; server-side TLS only). | SW_TRACEQL_REST_SSL_ENABLED | false |
@@ -306,6 +312,7 @@ It divided into several modules, each of which has its own settings. The followi
 | -                       | -             | lookback                                                                                                                                                                 | If the query does not provide a start time (default end time is current), this is the default look-back duration for traces and autocomplete tag queries (in milliseconds).                                                                                                           [...]
 | -                       | -             | zipkinTracesListResultTags                                                                                                                                               | Span tag keys included in the trace list result for the Zipkin data source. These tags are used for trace grouping and shown in the trace list panel. Multiple values are separated by commas.                                                                                        [...]
 | -                       | -             | skywalkingTracesListResultTags                                                                                                                                           | Span tag keys included in the trace list result for the SkyWalking native data source. These tags are used for trace grouping and shown in the trace list panel. Multiple values are separated by commas.                                                                             [...]
+| -                       | -             | otlpTracesListResultTags | Span attribute keys included in the trace list result of the OTLP data source, used by Grafana for grouping. | SW_TRACEQL_OTLP_TRACES_LIST_RESULT_TAGS | http.request.method,http.response.status_code |
 | alarm                   | default       | -                                                                                                                                                                        | Read [alarm doc](backend-alarm.md) for more details.                                                                                                                                                                                                                                  [...]
 | telemetry               | -             | -                                                                                                                                                                        | Read [telemetry doc](backend-telemetry.md) for more details.                                                                                                                                                                                                                          [...]
 | -                       | none          | -                                                                                                                                                                        | No op implementation.                                                                                                                                                                                                                                                                 [...]
@@ -503,6 +510,25 @@ OAP will query the data from the "hot and warm" stage by default if the "warm" s
 | -                      | -               | ttl             | TTL Days for this stage.                                                                                                                                                             | SW_STORAGE_BANYANDB_ZIPKIN_TRACE_COLD_TTL_DAYS        | 30        | 
 | -                      | -               | replicas        | Replicas for this stage.                                                                                                                                                             | SW_STORAGE_BANYANDB_ZIPKIN_TRACE_COLD_REPLICAS        | 0         |
 | -                      | -               | nodeSelector    | Specifying target nodes for this stage.                                                                                                                                              | SW_STORAGE_BANYANDB_ZIPKIN_TRACE_COLD_NODE_SELECTOR   | type=cold | 
+| recordsOtlpTrace       | -               | -               |                                                                                                                                                                                      | -                                                     | -         |
+| -                      | shardNum        | -               | Shards Number for OTLP trace records group.                                                                                                                                        | SW_STORAGE_BANYANDB_OTLP_TRACE_SHARD_NUM            | 2         | 
+| -                      | segmentInterval | -               | Segment Interval Days.                                                                                                                                                               | SW_STORAGE_BANYANDB_OTLP_TRACE_SI_DAYS              | 1         | 
+| -                      | ttl             | -               | TTL Days.                                                                                                                                                                            | SW_STORAGE_BANYANDB_OTLP_TRACE_TTL_DAYS             | 3         | 
+| -                      | replicas        | -               | Replicas for OTLP trace records group.                                                                                                                                             | SW_STORAGE_BANYANDB_OTLP_TRACE_REPLICAS             | 0         |
+| -                      | enableWarmStage | -               | Activate warm stage.                                                                                                                                                                 | SW_STORAGE_BANYANDB_OTLP_TRACE_ENABLE_WARM_STAGE    | false     | 
+| -                      | enableColdStage | -               | Activate cold stage.                                                                                                                                                                 | SW_STORAGE_BANYANDB_OTLP_TRACE_ENABLE_COLD_STAGE    | false     | 
+| -                      | warm            | -               | The warm stage settings.                                                                                                                                                             | -                                                     | -         | 
+| -                      | -               | shardNum        | Shards Number for this stage.                                                                                                                                                        | SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_SHARD_NUM       | 2         | 
+| -                      | -               | segmentInterval | Segment Interval Days for this stage.                                                                                                                                                | SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_SI_DAYS         | 1         | 
+| -                      | -               | ttl             | TTL Days for this stage.                                                                                                                                                             | SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_TTL_DAYS        | 7         | 
+| -                      | -               | replicas        | Replicas for this stage.                                                                                                                                                             | SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_REPLICAS        | 0         |
+| -                      | -               | nodeSelector    | Specifying target nodes for this stage.                                                                                                                                              | SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_NODE_SELECTOR   | type=warm | 
+| -                      | cold            | -               | The cold stage settings.                                                                                                                                                             | -                                                     | -         | 
+| -                      | -               | shardNum        | Shards Number for for this stage.                                                                                                                                                    | SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_SHARD_NUM       | 2         | 
+| -                      | -               | segmentInterval | Segment Interval Days for this stage.                                                                                                                                                | SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_SI_DAYS         | 1         | 
+| -                      | -               | ttl             | TTL Days for this stage.                                                                                                                                                             | SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_TTL_DAYS        | 30        | 
+| -                      | -               | replicas        | Replicas for this stage.                                                                                                                                                             | SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_REPLICAS        | 0         |
+| -                      | -               | nodeSelector    | Specifying target nodes for this stage.                                                                                                                                              | SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_NODE_SELECTOR   | type=cold | 
 | recordsLog             | -               | -               |                                                                                                                                                                                      | -                                                     | -         |
 | -                      | shardNum        | -               | Shards Number.                                                                                                                                                                       | SW_STORAGE_BANYANDB_LOG_SHARD_NUM                     | 2         | 
 | -                      | segmentInterval | -               | Segment Interval Days.                                                                                                                                                               | SW_STORAGE_BANYANDB_LOG_SI_DAYS                       | 1         | 
diff --git a/docs/en/setup/backend/otlp-trace.md b/docs/en/setup/backend/otlp-trace.md
index 75c082bbc6..941fdef4d3 100644
--- a/docs/en/setup/backend/otlp-trace.md
+++ b/docs/en/setup/backend/otlp-trace.md
@@ -1,9 +1,15 @@
 # OpenTelemetry Trace Format
 
-SkyWalking can receive traces in OTLP format and convert them to Zipkin Trace format eventually.
-For data analysis and queries related to Zipkin Trace, please [refer to the relevant documentation](./zipkin-trace.md#zipkin-query).
+SkyWalking receives traces in OTLP format through the `otlp-traces` handler of the OpenTelemetry receiver.
+The handler stores them in one of two ways, selected by `receiver-otel.default.otlpTraceStorage`:
 
-OTLP Trace handler references the [Zipkin Exporter in the OpenTelemetry Collector](https://opentelemetry.io/docs/specs/otel/trace/sdk_exporters/zipkin/#summary) to convert the data format.
+| `otlpTraceStorage` | What is stored                                                                                                   | Query API                                                                                  |
+|--------------------|------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
+| `otlp` (default)   | The span exactly as it arrived, with its resource and instrumentation scope, in the `otlp_span` record.          | The TraceQL `/otlp` datasource, served to Grafana Tempo as native OTLP.                     |
+| `zipkin`           | Spans converted to Zipkin v2 and handed to the Zipkin receiver, the only mode of earlier releases. Follows the [Zipkin Exporter](https://opentelemetry.io/docs/specs/otel/trace/sdk_exporters/zipkin/#summary) conversion rules. | [Zipkin query API](./zipkin-trace.md#zipkin-query), Lens UI, and the TraceQL `/zipkin` datasource. |
+
+Span listeners such as the GenAI analyzer and AI evaluation run in both modes. In `otlp` mode they read the
+OTLP span directly instead of its Zipkin conversion.
 
 ## Supported Protocols
 
@@ -17,18 +23,95 @@ Both **OTLP/gRPC** and **OTLP/HTTP** are supported for traces, logs, and metrics
 
 OTLP/HTTP supports both `application/x-protobuf` and `application/json` content types.
 
-## Set up backend receiver
+## Zipkin conversion mode
+
+1. Make sure to enable the **otlp-traces** handler in the OTLP receiver of `application.yml`.
+```yaml
+receiver-otel:
+  selector: default
+  default:
+    enabledHandlers: otlp-traces
+    otlpTraceStorage: zipkin
+```
+
+2. Enable the Zipkin receiver and the Zipkin query in `application.yml`, they hold the storage and the query of the
+   converted spans. The OAP refuses to start when `otlp-traces` runs in this mode with `receiver-zipkin` disabled.
+
+3. Read [deploy Lens UI documentation](./zipkin-trace.md#lens-ui) to query the traces, or enable the
+   TraceQL `/zipkin` datasource for Grafana, see [TraceQL Service](../../api/traceql-service.md).
+
+The conversion is lossy, because the Zipkin v2 model is smaller than OTLP. What the Zipkin copy cannot keep:
+
+- **Span kind.** Zipkin has `CLIENT`, `SERVER`, `PRODUCER` and `CONSUMER` only. An OTLP `SPAN_KIND_INTERNAL` span is
+  stored without a kind and reads back as `SPAN_KIND_UNSPECIFIED`; the receiver adds a `span.kind=internal` tag so the
+  information is searchable as an ordinary tag, but the TraceQL `kind` intrinsic cannot match it.
+- **Attribute types.** Every attribute becomes a string tag, so an integer such as `http.response.status_code` reads
+  back as `"200"`.
+- **Events and links.** An event becomes a Zipkin annotation; when it carries attributes they are flattened into the
+  annotation value as `name|{attributes}|droppedCount`. A link becomes an `otlp.link.<n>` tag holding the trace and
+  span ids in decimal, the trace state and the flattened attributes.
+- **Resource and instrumentation scope.** Resource attributes are merged into the span tags, and the scope name and
+  version are kept as `otel.library.name` and `otel.library.version` tags.
+
+Choose the [native OTLP mode](#native-otlp-mode) when any of these matters.
+
+## Native OTLP mode
 
-1. Make sure to enable **otlp-traces** handler in OTLP receiver of `application.yml`.
 ```yaml
 receiver-otel:
   selector: default
   default:
     enabledHandlers: otlp-traces
+    otlpTraceStorage: otlp
+    otlpTraceSearchableTags: http.request.method,http.response.status_code,url.path,rpc.method,rpc.grpc.status_code,db.system.name,db.namespace,messaging.system,messaging.destination.name,peer.service
+    otlpTraceSampleRate: 10000
+    otlpTraceMaxSpansPerSecond: 0
+
+traceQL:
+  selector: default
+  default:
+    enableDatasourceOTLP: true
+    restContextPathOTLP: /otlp
+    otlpTracesListResultTags: http.request.method,http.response.status_code
 ```
 
-2. Make sure to enable zipkin receiver and zipkin query in `application.yml` for config the zipkin.
+The Zipkin receiver and query modules are not needed in this mode.
+
+| Setting                      | Environment variable                 | Meaning                                                                                                                                                                              |
+|------------------------------|--------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `otlpTraceStorage`           | `SW_OTEL_TRACE_STORAGE`              | `otlp`, the default, stores the native span. `zipkin` converts to Zipkin v2 as earlier releases did. Any other value fails the boot.                                               |
+| `otlpTraceSearchableTags`    | `SW_OTEL_TRACE_SEARCHABLE_TAG_KEYS`  | Attribute keys offered by tag autocomplete (`/api/v2/search/tags`). Every resource and span attribute is indexed for equality search regardless of this list.                        |
+| `otlpTraceSampleRate`        | `SW_OTEL_TRACE_SAMPLE_RATE`          | Head sampling by trace id, precision 1/10000. `10000` keeps every trace.                                                                                                              |
+| `otlpTraceMaxSpansPerSecond` | `SW_OTEL_TRACE_MAX_SPANS_PER_SECOND` | Spans per second accepted before the receiver drops the rest. `0` means no limit. Dropped spans are counted in the `otel_spans_dropped` self-observability metric.                    |
+
+### Service name
+
+The service name is taken from the resource attributes in this order: `service.name`, `faas.name`,
+`k8s.deployment.name`, `process.executable.name`. The attributes themselves are not rewritten. A span whose resource
+carries none of these keys is rejected and reported in the OTLP `partial_success` response, it is never stored as an
+anonymous span.
+
+### What is stored
+
+Every span is stored as a single-span `ResourceSpans` message, so the query returns the exact resource,
+instrumentation scope, attributes, events, links and status the SDK sent. A few columns are extracted
+for search: service name and instance (`service.instance.id`), instrumentation scope name, span name,
+kind, status code, `peer.service`, start time and duration. The service, span and peer names go through the core
+module's naming rules first, the length limits and the endpoint grouping rules SkyWalking applies to its own
+services and endpoints, so a TraceQL `name` matches the grouped name while the stored span keeps the original. Every resource attribute is indexed as a
+`resource.<key>=<value>` tag and every span attribute as a `span.<key>=<value>` tag, so a scoped TraceQL attribute
+matches its own scope only and an unscoped `.<key>` matches either.
+
+The BanyanDB storage keeps the spans in the `otlpTrace` group, see [BanyanDB TTL](../../banyandb/ttl.md)
+for its default retention and [BanyanDB storage](./storages/banyandb.md) for the group settings.
+The JDBC storages add an `otlp_span_tag` table for the tag index.
+
+### Query
 
-## Setup Query and Lens UI 
+Point a Grafana Tempo datasource at `http://<oap-host>:3200/otlp`, see
+[Use Grafana As The UI](./ui-grafana.md#tempo-data-source). The supported TraceQL subset and the HTTP API are
+described in [TraceQL Service](../../api/traceql-service.md#otlp-native-trace).
 
-Please read [deploy Lens UI documentation](./zipkin-trace.md#lens-ui) for query OTLP traces.
\ No newline at end of file
+The status module exposes the same queries with the execution trace for debugging under
+`/debugging/query/otlp/api/search` and `/debugging/query/otlp/api/v2/trace`, see
+[Status APIs](./admin-api/status.md).
diff --git a/docs/en/setup/backend/storages/banyandb.md b/docs/en/setup/backend/storages/banyandb.md
index 662ca29b53..c51677ce8b 100644
--- a/docs/en/setup/backend/storages/banyandb.md
+++ b/docs/en/setup/backend/storages/banyandb.md
@@ -180,6 +180,25 @@ groups:
             durationThresholdMs: ${SW_STORAGE_BANYANDB_ZIPKIN_TRACE_SAMPLER_DURATION_THRESHOLD_MS:1000}
             keepErrors: ${SW_STORAGE_BANYANDB_ZIPKIN_TRACE_SAMPLER_KEEP_ERRORS:true}
             healthySampleRate: ${SW_STORAGE_BANYANDB_ZIPKIN_TRACE_SAMPLER_HEALTHY_SAMPLE_RATE:0.05}
+  otlpTrace:
+    shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_SHARD_NUM:2}
+    segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_SI_DAYS:1}
+    ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_TTL_DAYS:3}
+    replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_REPLICAS:0}
+    enableWarmStage: ${SW_STORAGE_BANYANDB_OTLP_TRACE_ENABLE_WARM_STAGE:false}
+    enableColdStage: ${SW_STORAGE_BANYANDB_OTLP_TRACE_ENABLE_COLD_STAGE:false}
+    warm:
+      shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_SHARD_NUM:2}
+      segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_SI_DAYS:1}
+      ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_TTL_DAYS:7}
+      replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_REPLICAS:0}
+      nodeSelector: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_NODE_SELECTOR:"type=warm"}
+    cold:
+      shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_SHARD_NUM:2}
+      segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_SI_DAYS:1}
+      ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_TTL_DAYS:30}
+      replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_REPLICAS:0}
+      nodeSelector: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_NODE_SELECTOR:"type=cold"}
   recordsLog:
     shardNum: ${SW_STORAGE_BANYANDB_LOG_SHARD_NUM:2}
     segmentInterval: ${SW_STORAGE_BANYANDB_LOG_SI_DAYS:1}
diff --git a/docs/en/setup/backend/ui-grafana.md b/docs/en/setup/backend/ui-grafana.md
index 1f52506c9e..b08555d71a 100644
--- a/docs/en/setup/backend/ui-grafana.md
+++ b/docs/en/setup/backend/ui-grafana.md
@@ -23,11 +23,16 @@ In the data source config panel, choose the `Loki` and set the url to the OAP se
 <img src="https://skywalking.apache.org/screenshots/9.6.0/logql/grafana-loki-datasource.jpg"/>
 
 ### Tempo Data Source
-In the data source config panel, choose the `Tempo` and set the url to the full OAP trace API address, including the context path. The default port is `3200`, for example `http://<oap-host>:3200/skywalking` for SkyWalking native trace or `http://<oap-host>:3200/zipkin` for Zipkin trace.
-The SkyWalking native trace API context path is `/skywalking`, and the Zipkin trace API context path is `/zipkin`.
-You can customize them via `SW_TRACEQL_REST_CONTEXT_PATH_SKYWALKING` and `SW_TRACEQL_REST_CONTEXT_PATH_ZIPKIN` respectively. If you customize either path, use the customized path in the Grafana Tempo data source URL.
+In the data source config panel, choose the `Tempo` and set the url to the full OAP trace API address, including the context path. The default port is `3200`, for example `http://<oap-host>:3200/skywalking` for SkyWalking native trace, `http://<oap-host>:3200/zipkin` for Zipkin trace, or `http://<oap-host>:3200/otlp` for natively stored OTLP trace.
+The SkyWalking native trace API context path is `/skywalking`, the Zipkin trace API context path is `/zipkin`, and the OTLP trace API context path is `/otlp`.
+You can customize them via `SW_TRACEQL_REST_CONTEXT_PATH_SKYWALKING`, `SW_TRACEQL_REST_CONTEXT_PATH_ZIPKIN` and `SW_TRACEQL_REST_CONTEXT_PATH_OTLP` respectively. If you customize a path, use the customized path in the Grafana Tempo data source URL.
+The OTLP data source serves the spans `receiver-otel` stores with `otlpTraceStorage: otlp`, the default, see [OpenTelemetry Trace Format](otlp-trace.md).
 
 ***Notice:*** The feature requires version `Grafana 12 or later`. And require disabling the Streaming option for the Tempo data source in the following configuration.
+
+***Notice:*** OR (`||`) is not supported in TraceQL. Grafana's query builder emits `{(a || b)}` when several values of one
+filter are selected, so select one value per filter; a query with `||` is refused with `400 OR (||) is not supported`.
+The supported TraceQL subset is listed in [TraceQL Service](../../api/traceql-service.md#details-of-supported-traceql).
 <img src="https://skywalking.apache.org/screenshots/10.4.0/traceql/grafana-tempo-datasource-streaming.png"/>
 
 #### SkyWalking Native Trace
diff --git a/oap-server/analyzer/ai-evaluation/src/main/java/org/apache/skywalking/oap/server/ai/evaluation/listener/AIEvaluationSpanListener.java b/oap-server/analyzer/ai-evaluation/src/main/java/org/apache/skywalking/oap/server/ai/evaluation/listener/AIEvaluationSpanListener.java
index a9de875f36..1bc9afc883 100644
--- a/oap-server/analyzer/ai-evaluation/src/main/java/org/apache/skywalking/oap/server/ai/evaluation/listener/AIEvaluationSpanListener.java
+++ b/oap-server/analyzer/ai-evaluation/src/main/java/org/apache/skywalking/oap/server/ai/evaluation/listener/AIEvaluationSpanListener.java
@@ -23,6 +23,8 @@ import com.google.gson.JsonObject;
 import org.apache.skywalking.oap.server.ai.evaluation.AIEvaluationModule;
 import org.apache.skywalking.oap.server.ai.evaluation.context.AIEvaluationContext;
 import org.apache.skywalking.oap.server.ai.evaluation.service.IAIEvaluationService;
+import org.apache.skywalking.oap.server.core.otlp.OTLPSpanRecord;
+import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
 import org.apache.skywalking.oap.server.core.trace.SpanListener;
 import org.apache.skywalking.oap.server.core.trace.SpanListenerResult;
 import org.apache.skywalking.oap.server.core.query.enumeration.GenAITraceRefType;
@@ -34,9 +36,11 @@ import org.apache.skywalking.oap.server.library.util.genai.GenAISemanticAttribut
 
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 public class AIEvaluationSpanListener implements SpanListener {
     private static final String ERROR_TAG = "error";
+    private static final String STATUS_CODE_ERROR = "STATUS_CODE_ERROR";
     private static final long MILLIS_PER_MICRO = 1_000L;
     private static final String[] REQUIRED_TAG_KEYS = new String[]{
             GenAISemanticAttributes.RESPONSE_MODEL,
@@ -84,6 +88,59 @@ public class AIEvaluationSpanListener implements SpanListener {
         return SpanListenerResult.CONTINUE;
     }
 
+    /**
+     * The same sampling over a natively stored OTLP span, which never becomes a Zipkin span. The {@code error}
+     * tag the Zipkin conversion writes is derived from the span status here, so the evaluation context reads the
+     * same on both paths.
+     */
+    @Override
+    public SpanListenerResult onNativeOTLPSpan(final OTLPSpanReader span,
+                                               final Map<String, String> resourceAttributes,
+                                               final String scopeName,
+                                               final String scopeVersion) {
+        final Map<String, String> attributes = span.attributes();
+        final Map<String, String> tags = new HashMap<>();
+        for (String requiredKey : REQUIRED_TAG_KEYS) {
+            final String value = attributes.get(requiredKey);
+            if (StringUtil.isNotEmpty(value)) {
+                tags.put(requiredKey, value);
+            }
+        }
+        final boolean error = STATUS_CODE_ERROR.equals(span.statusCode());
+        if (error) {
+            tags.put(ERROR_TAG, "true");
+        }
+        if (!isGenAISpan(tags)) {
+            return SpanListenerResult.CONTINUE;
+        }
+        if (!shouldSample(span.traceId())) {
+            return SpanListenerResult.CONTINUE;
+        }
+        sample(span.traceId(),
+                span.spanId(),
+                resolveServiceName(resourceAttributes),
+                span.spanName(),
+                TimeUnit.NANOSECONDS.toMillis(span.startTimeNanos()),
+                TimeUnit.NANOSECONDS.toMillis(span.endTimeNanos()),
+                tags,
+                error);
+        return SpanListenerResult.CONTINUE;
+    }
+
+    /**
+     * The service the span is stored under: the same resource keys, in the same order, the OTLP receiver resolves
+     * {@code service_name} from, so the evaluation record joins back to the trace's service.
+     */
+    private static String resolveServiceName(final Map<String, String> resourceAttributes) {
+        for (final String key : OTLPSpanRecord.SERVICE_NAME_RESOURCE_KEYS) {
+            final String value = resourceAttributes.get(key);
+            if (StringUtil.isNotEmpty(value)) {
+                return value;
+            }
+        }
+        return "";
+    }
+
     private void sample(final String traceId,
                         final String spanId,
                         final String serviceName,
diff --git a/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/listener/GenAISpanListener.java b/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/listener/GenAISpanListener.java
index 88ba3c88dc..1c7f6a8df7 100644
--- a/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/listener/GenAISpanListener.java
+++ b/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/listener/GenAISpanListener.java
@@ -25,6 +25,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.apache.skywalking.oap.analyzer.genai.module.GenAIAnalyzerModule;
 import org.apache.skywalking.oap.analyzer.genai.service.IGenAIMeterAnalyzerService;
 import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
 import org.apache.skywalking.oap.server.core.trace.SpanListener;
 import org.apache.skywalking.oap.server.core.trace.SpanListenerResult;
 import org.apache.skywalking.oap.server.core.source.GenAIMetrics;
@@ -35,11 +36,12 @@ import org.apache.skywalking.oap.server.library.module.ModuleManager;
 import org.apache.skywalking.oap.server.library.util.genai.GenAISemanticAttributes;
 
 /**
- * {@link SpanListener} that extracts GenAI metrics from Zipkin spans.
+ * {@link SpanListener} that extracts GenAI metrics from spans after phase 1.
  *
  * <p>Implements {@link #onZipkinSpan} (phase 2) so it works for all trace sources
- * (OTLP, Zipkin HTTP, Kafka). Replicates the logic previously hardcoded in
- * {@code SpanForward.processGenAILogic()}.
+ * (OTLP converted to Zipkin, Zipkin HTTP, Kafka), and {@link #onNativeOTLPSpan} for OTLP
+ * spans stored natively, which never become Zipkin spans. Each span reaches exactly one
+ * of the two. Replicates the logic previously hardcoded in {@code SpanForward.processGenAILogic()}.
  *
  * <p>Sources (GenAI metrics) are emitted directly to {@link SourceReceiver} — not
  * returned in the result. The result only carries additional tags (estimated cost).
@@ -66,7 +68,18 @@ public class GenAISpanListener implements SpanListener {
 
     @Override
     public SpanListenerResult onZipkinSpan(final ZipkinSpan span) {
-        final GenAIMetrics metrics = analyzerService.extractMetricsFromZipkinSpan(span);
+        return emit(analyzerService.extractMetricsFromZipkinSpan(span));
+    }
+
+    @Override
+    public SpanListenerResult onNativeOTLPSpan(final OTLPSpanReader span,
+                                               final Map<String, String> resourceAttributes,
+                                               final String scopeName,
+                                               final String scopeVersion) {
+        return emit(analyzerService.extractMetricsFromOTLPSpan(span));
+    }
+
+    private SpanListenerResult emit(final GenAIMetrics metrics) {
         if (metrics == null) {
             return SpanListenerResult.CONTINUE;
         }
diff --git a/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/service/GenAIMeterAnalyzer.java b/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/service/GenAIMeterAnalyzer.java
index 70a2cbd9c1..bf1308167e 100644
--- a/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/service/GenAIMeterAnalyzer.java
+++ b/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/service/GenAIMeterAnalyzer.java
@@ -35,6 +35,7 @@ import org.apache.skywalking.oap.server.core.source.GenAIProviderAccess;
 import org.apache.skywalking.oap.server.core.source.ServiceInstance;
 import org.apache.skywalking.oap.server.core.source.ServiceMeta;
 import org.apache.skywalking.oap.server.core.source.Source;
+import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
 import org.apache.skywalking.oap.server.core.zipkin.source.ZipkinSpan;
 import org.apache.skywalking.oap.server.library.util.StringUtil;
 import org.apache.skywalking.oap.server.library.util.genai.GenAIContextResolver;
@@ -45,11 +46,24 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 import static java.util.stream.Collectors.toMap;
 
 @Slf4j
 public class GenAIMeterAnalyzer implements IGenAIMeterAnalyzerService {
+    private static final String STATUS_CODE_ERROR = "STATUS_CODE_ERROR";
+    /**
+     * The attributes {@link #extractMetrics} reads, copied out of a Zipkin span's tag object.
+     */
+    private static final String[] GEN_AI_KEYS = {
+        GenAISemanticAttributes.RESPONSE_MODEL,
+        GenAISemanticAttributes.PROVIDER_NAME,
+        GenAISemanticAttributes.SYSTEM_NAME,
+        GenAISemanticAttributes.USAGE_INPUT_TOKENS,
+        GenAISemanticAttributes.USAGE_OUTPUT_TOKENS,
+        GenAISemanticAttributes.SERVER_TIME_TO_FIRST_TOKEN
+    };
 
     private final GenAIProviderPrefixMatcher matcher;
 
@@ -113,16 +127,54 @@ public class GenAIMeterAnalyzer implements IGenAIMeterAnalyzerService {
 
     @Override
     public GenAIMetrics extractMetricsFromZipkinSpan(ZipkinSpan zipkinSpan) {
-        JsonObject tags = zipkinSpan.getTags();
-        JsonElement element = tags.get(GenAISemanticAttributes.RESPONSE_MODEL);
-        if (element == null || StringUtil.isBlank(element.getAsString())) {
+        final JsonObject tags = zipkinSpan.getTags();
+        final Map<String, String> attributes = new HashMap<>();
+        for (final String key : GEN_AI_KEYS) {
+            final String value = getZipkinSpanTagValue(tags, key);
+            if (StringUtil.isNotBlank(value)) {
+                attributes.put(key, value);
+            }
+        }
+        return extractMetrics(
+            attributes,
+            zipkinSpan.getDuration() / 1000,
+            StringUtil.isBlank(getZipkinSpanTagValue(tags, "error")),
+            zipkinSpan.getTimestamp() / 1000
+        );
+    }
+
+    @Override
+    public GenAIMetrics extractMetricsFromOTLPSpan(final OTLPSpanReader span) {
+        return extractMetrics(
+            span.attributes(),
+            TimeUnit.NANOSECONDS.toMillis(span.endTimeNanos() - span.startTimeNanos()),
+            !STATUS_CODE_ERROR.equals(span.statusCode()),
+            TimeUnit.NANOSECONDS.toMillis(span.startTimeNanos())
+        );
+    }
+
+    /**
+     * The attribute-driven part shared by the Zipkin and the native OTLP path: both carry the GenAI semantic
+     * conventions as string attributes; only latency, status and the timestamp are read differently.
+     *
+     * @param attributes      span attributes as strings
+     * @param latencyMillis   the span's duration in milliseconds
+     * @param status          true when the span did not fail
+     * @param startTimeMillis the span's start in milliseconds since the epoch
+     * @return null when the span carries no {@code gen_ai.response.model}
+     */
+    private GenAIMetrics extractMetrics(final Map<String, String> attributes,
+                                        final long latencyMillis,
+                                        final boolean status,
+                                        final long startTimeMillis) {
+        if (StringUtil.isBlank(attributes.get(GenAISemanticAttributes.RESPONSE_MODEL))) {
             return null;
         }
 
         final Map<String, String> contextTags = new HashMap<>();
-        putTag(contextTags, tags, GenAISemanticAttributes.RESPONSE_MODEL);
-        putTag(contextTags, tags, GenAISemanticAttributes.PROVIDER_NAME);
-        putTag(contextTags, tags, GenAISemanticAttributes.SYSTEM_NAME);
+        putAttribute(contextTags, attributes, GenAISemanticAttributes.RESPONSE_MODEL);
+        putAttribute(contextTags, attributes, GenAISemanticAttributes.PROVIDER_NAME);
+        putAttribute(contextTags, attributes, GenAISemanticAttributes.SYSTEM_NAME);
         final GenAIContextResolver.Result resolvedContext = GenAIContextResolver.resolve(contextTags);
         String modelName = resolvedContext.getModelName();
         String provider = resolvedContext.getProviderName();
@@ -131,8 +183,8 @@ public class GenAIMeterAnalyzer implements IGenAIMeterAnalyzerService {
 
         GenAIConfig.Model modelConfig = matchResult.getModelConfig();
 
-        long inputTokens = parseSafeLong(getZipkinSpanTagValue(tags, GenAISemanticAttributes.USAGE_INPUT_TOKENS));
-        long outputTokens = parseSafeLong(getZipkinSpanTagValue(tags, GenAISemanticAttributes.USAGE_OUTPUT_TOKENS));
+        long inputTokens = parseSafeLong(attributes.get(GenAISemanticAttributes.USAGE_INPUT_TOKENS));
+        long outputTokens = parseSafeLong(attributes.get(GenAISemanticAttributes.USAGE_OUTPUT_TOKENS));
 
         double totalCost = calculateTotalCost(modelConfig, inputTokens, outputTokens);
 
@@ -142,19 +194,18 @@ public class GenAIMeterAnalyzer implements IGenAIMeterAnalyzerService {
         metrics.setModelName(modelName);
         metrics.setInputTokens(inputTokens);
         metrics.setOutputTokens(outputTokens);
-        metrics.setTimeToFirstToken(parseSafeInt(
-            getZipkinSpanTagValue(tags, GenAISemanticAttributes.SERVER_TIME_TO_FIRST_TOKEN)));
+        metrics.setTimeToFirstToken(parseSafeInt(attributes.get(GenAISemanticAttributes.SERVER_TIME_TO_FIRST_TOKEN)));
         metrics.setTotalEstimatedCost(totalCost);
-        metrics.setLatency(zipkinSpan.getDuration() / 1000);
-        metrics.setStatus(StringUtil.isBlank(getZipkinSpanTagValue(tags, "error")));
-        metrics.setTimeBucket(TimeBucket.getMinuteTimeBucket(zipkinSpan.getTimestamp() / 1000));
+        metrics.setLatency(latencyMillis);
+        metrics.setStatus(status);
+        metrics.setTimeBucket(TimeBucket.getMinuteTimeBucket(startTimeMillis));
         return metrics;
     }
 
-    private void putTag(final Map<String, String> target,
-                        final JsonObject source,
-                        final String key) {
-        final String value = getZipkinSpanTagValue(source, key);
+    private static void putAttribute(final Map<String, String> target,
+                                     final Map<String, String> source,
+                                     final String key) {
+        final String value = source.get(key);
         if (StringUtil.isNotBlank(value)) {
             target.put(key, value);
         }
diff --git a/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/service/IGenAIMeterAnalyzerService.java b/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/service/IGenAIMeterAnalyzerService.java
index e29b3daa16..da56463318 100644
--- a/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/service/IGenAIMeterAnalyzerService.java
+++ b/oap-server/analyzer/gen-ai-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/genai/service/IGenAIMeterAnalyzerService.java
@@ -22,6 +22,7 @@ import org.apache.skywalking.apm.network.language.agent.v3.SegmentObject;
 import org.apache.skywalking.apm.network.language.agent.v3.SpanObject;
 import org.apache.skywalking.oap.server.core.source.GenAIMetrics;
 import org.apache.skywalking.oap.server.core.source.Source;
+import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
 import org.apache.skywalking.oap.server.core.zipkin.source.ZipkinSpan;
 import org.apache.skywalking.oap.server.library.module.Service;
 
@@ -33,6 +34,14 @@ public interface IGenAIMeterAnalyzerService extends Service {
 
     GenAIMetrics extractMetricsFromZipkinSpan(ZipkinSpan zipkinSpan);
 
+    /**
+     * The counterpart of {@link #extractMetricsFromZipkinSpan} for OTLP spans stored natively, which never become
+     * Zipkin spans. Reads the same attributes; status comes from the span status instead of the {@code error} tag.
+     *
+     * @return null when the span carries no {@code gen_ai.response.model}
+     */
+    GenAIMetrics extractMetricsFromOTLPSpan(OTLPSpanReader span);
+
     List<Source> transferToSources(GenAIMetrics metrics);
 
 }
diff --git a/oap-server/server-admin/status/pom.xml b/oap-server/server-admin/status/pom.xml
index fe835aa1f6..1d7532bc08 100644
--- a/oap-server/server-admin/status/pom.xml
+++ b/oap-server/server-admin/status/pom.xml
@@ -61,6 +61,15 @@
             <artifactId>zipkin-query-plugin</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <!--
+          traceql-plugin: the OTLP debug-trace endpoints route through the
+          same TraceQL handler the /otlp datasource uses.
+        -->
+        <dependency>
+            <groupId>org.apache.skywalking</groupId>
+            <artifactId>traceql-plugin</artifactId>
+            <version>${project.version}</version>
+        </dependency>
         <!--
           server-alarm-plugin: alarm runtime status queries inspect the
           alarm provider's in-memory rule + window state.
diff --git a/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/DebuggingHTTPHandler.java b/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/DebuggingHTTPHandler.java
index c0a67ce0b4..4daf851a0d 100644
--- a/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/DebuggingHTTPHandler.java
+++ b/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/DebuggingHTTPHandler.java
@@ -20,6 +20,7 @@ package org.apache.skywalking.oap.server.admin.status;
 
 import com.fasterxml.jackson.annotation.JsonAutoDetect;
 import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.SerializationFeature;
 import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
@@ -27,6 +28,7 @@ import com.google.common.reflect.TypeToken;
 import com.google.gson.Gson;
 import com.linecorp.armeria.common.AggregatedHttpResponse;
 import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.MediaType;
 import com.linecorp.armeria.server.annotation.Default;
 import com.linecorp.armeria.server.annotation.ExceptionHandler;
 import com.linecorp.armeria.server.annotation.Get;
@@ -34,6 +36,7 @@ import com.linecorp.armeria.server.annotation.Param;
 import com.linecorp.armeria.server.annotation.ProducesJson;
 import com.linecorp.armeria.server.annotation.ProducesText;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -50,12 +53,16 @@ import org.apache.skywalking.oap.server.admin.status.topology.DebuggingQueryProc
 import org.apache.skywalking.oap.server.admin.status.topology.DebuggingQueryServiceTopologyRsp;
 import org.apache.skywalking.oap.server.admin.status.trace.DebuggingQueryTraceBriefRsp;
 import org.apache.skywalking.oap.server.admin.status.trace.DebuggingQueryTraceRsp;
+import org.apache.skywalking.oap.server.admin.status.trace.otlp.DebuggingOTLPQueryTraceRsp;
+import org.apache.skywalking.oap.server.admin.status.trace.otlp.DebuggingOTLPQueryTracesRsp;
 import org.apache.skywalking.oap.server.admin.status.trace.zipkin.DebuggingZipkinQueryTraceRsp;
 import org.apache.skywalking.oap.server.admin.status.trace.zipkin.DebuggingZipkinQueryTracesRsp;
 import org.apache.skywalking.oap.query.graphql.resolver.LogQuery;
 import org.apache.skywalking.oap.query.graphql.resolver.MetricsExpressionQuery;
 import org.apache.skywalking.oap.query.graphql.resolver.TopologyQuery;
 import org.apache.skywalking.oap.query.graphql.resolver.TraceQuery;
+import org.apache.skywalking.oap.query.traceql.TraceQLConfig;
+import org.apache.skywalking.oap.query.traceql.handler.OTLPTraceQLApiHandler;
 import org.apache.skywalking.oap.query.zipkin.ZipkinQueryConfig;
 import org.apache.skywalking.oap.query.zipkin.handler.ZipkinQueryHandler;
 import org.apache.skywalking.oap.server.core.Const;
@@ -96,6 +103,7 @@ public class DebuggingHTTPHandler {
     private final MetricsExpressionQuery mqeQuery;
     private final TraceQuery traceQuery;
     private final ZipkinQueryHandler zipkinQueryHandler;
+    private final OTLPTraceQLApiHandler otlpTraceQLApiHandler;
     private final TopologyQuery topologyQuery;
     private final LogQuery logQuery;
     final StatusModuleConfig config;
@@ -109,6 +117,8 @@ public class DebuggingHTTPHandler {
         this.traceQuery = new TraceQuery(manager);
         //use zipkin default config for debugging
         this.zipkinQueryHandler = new ZipkinQueryHandler(new ZipkinQueryConfig(), manager);
+        //use TraceQL default config for debugging
+        this.otlpTraceQLApiHandler = new OTLPTraceQLApiHandler(manager, new TraceQLConfig());
         this.topologyQuery = new TopologyQuery(manager);
         this.logQuery = new LogQuery(manager);
     }
@@ -335,6 +345,83 @@ public class DebuggingHTTPHandler {
         }
     }
 
+    /**
+     * The TraceQL {@code /otlp/api/search} query with its execution trace. {@code q} is a TraceQL expression,
+     * {@code start} and {@code end} are epoch seconds, {@code minDuration} and {@code maxDuration} TraceQL durations.
+     */
+    @SneakyThrows
+    @Get("/debugging/query/otlp/api/search")
+    public String queryOTLPTraces(@Param("q") Optional<String> q,
+                                  @Param("tags") Optional<String> tags,
+                                  @Param("minDuration") Optional<String> minDuration,
+                                  @Param("maxDuration") Optional<String> maxDuration,
+                                  @Param("limit") Optional<Integer> limit,
+                                  @Param("start") Optional<Long> start,
+                                  @Param("end") Optional<Long> end,
+                                  @Param("coldStage") Optional<Boolean> coldStage) {
+        final String condition = "q: " + q.orElse(null) +
+            ", tags: " + tags.orElse(null) +
+            ", minDuration: " + minDuration.orElse(null) +
+            ", maxDuration: " + maxDuration.orElse(null) +
+            ", limit: " + limit.orElse(null) +
+            ", start: " + start.orElse(null) +
+            ", end: " + end.orElse(null) +
+            ", coldStage: " + coldStage.orElse(null);
+        DebuggingTraceContext traceContext = new DebuggingTraceContext(condition, true, false);
+        DebuggingTraceContext.TRACE_CONTEXT.set(traceContext);
+        try {
+            AggregatedHttpResponse response = otlpTraceQLApiHandler.search(
+                q, tags, minDuration, maxDuration, limit, start, end, Optional.empty(), coldStage
+            ).aggregate().join();
+            DebuggingOTLPQueryTracesRsp result = new DebuggingOTLPQueryTracesRsp(
+                jsonField(response, "traces"), transformTrace(traceContext.getExecTrace()));
+            return transToYAMLString(result);
+        } finally {
+            traceContext.stopTrace();
+            DebuggingTraceContext.TRACE_CONTEXT.remove();
+        }
+    }
+
+    /**
+     * The TraceQL {@code /otlp/api/v2/traces/{traceId}} query with its execution trace. Only BanyanDB can query
+     * the trace in the cold stage, which needs {@code start} and {@code end} in epoch seconds.
+     */
+    @SneakyThrows
+    @Get("/debugging/query/otlp/api/v2/trace")
+    public String getOTLPTraceById(@Param("traceId") String traceId,
+                                   @Param("start") Optional<Long> start,
+                                   @Param("end") Optional<Long> end,
+                                   @Param("coldStage") Optional<Boolean> coldStage) {
+        final String condition = "traceId: " + traceId +
+            ", start: " + start.orElse(null) +
+            ", end: " + end.orElse(null) +
+            ", coldStage: " + coldStage.orElse(null);
+        DebuggingTraceContext traceContext = new DebuggingTraceContext(condition, true, false);
+        DebuggingTraceContext.TRACE_CONTEXT.set(traceContext);
+        try {
+            AggregatedHttpResponse response = otlpTraceQLApiHandler.queryTrace(
+                traceId, start, end, coldStage, Optional.of(MediaType.JSON.toString())
+            ).aggregate().join();
+            DebuggingOTLPQueryTraceRsp result = new DebuggingOTLPQueryTraceRsp(
+                jsonField(response, "trace"), transformTrace(traceContext.getExecTrace()));
+            return transToYAMLString(result);
+        } finally {
+            traceContext.stopTrace();
+            DebuggingTraceContext.TRACE_CONTEXT.remove();
+        }
+    }
+
+    /**
+     * The named field of a 200 JSON response; the whole body otherwise, so an error message stays visible.
+     */
+    private static JsonNode jsonField(final AggregatedHttpResponse response, final String field) throws IOException {
+        final JsonNode body = new ObjectMapper().readTree(response.contentUtf8());
+        if (response.status().code() == 200 && body != null && body.has(field)) {
+            return body.get(field);
+        }
+        return body;
+    }
+
     @SneakyThrows
     @Get("/debugging/query/topology/getGlobalTopology")
     public String getGlobalTopology(@Param("startTime") String startTime,
diff --git a/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/StatusModuleProvider.java b/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/StatusModuleProvider.java
index 6c838124cb..92920da528 100644
--- a/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/StatusModuleProvider.java
+++ b/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/StatusModuleProvider.java
@@ -22,6 +22,7 @@ import com.linecorp.armeria.common.HttpMethod;
 import java.util.Collections;
 import org.apache.skywalking.oap.server.admin.server.module.AdminServerModule;
 import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.storage.StorageModule;
 import org.apache.skywalking.oap.server.core.server.HTTPHandlerRegister;
 import org.apache.skywalking.oap.server.library.module.ModuleConfig;
 import org.apache.skywalking.oap.server.library.module.ModuleDefine;
@@ -85,6 +86,8 @@ public class StatusModuleProvider extends ModuleProvider {
         return new String[] {
             CoreModule.NAME,
             AdminServerModule.NAME,
+            // The debugging queries reach the storage DAOs through the query services they construct.
+            StorageModule.NAME,
         };
     }
 
diff --git a/oap-server/server-core/src/main/proto/SpanWrapper.proto b/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/trace/otlp/DebuggingOTLPQueryTraceRsp.java
similarity index 60%
copy from oap-server/server-core/src/main/proto/SpanWrapper.proto
copy to oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/trace/otlp/DebuggingOTLPQueryTraceRsp.java
index 823e3bebd5..7b616e79a7 100644
--- a/oap-server/server-core/src/main/proto/SpanWrapper.proto
+++ b/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/trace/otlp/DebuggingOTLPQueryTraceRsp.java
@@ -16,21 +16,19 @@
  *
  */
 
-syntax = "proto3";
+package org.apache.skywalking.oap.server.admin.status.trace.otlp;
 
-package org.apache.skywalking.oap.server.core.storage.query;
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.apache.skywalking.oap.server.admin.status.DebuggingTraceRsp;
 
-option java_package = "org.apache.skywalking.oap.server.core.storage.query.proto";
-option java_multiple_files = true;
-
-message SpanWrapper {
-    bytes span = 1;
-    Source source = 2;
+/**
+ * The {@code trace} of a TraceQL {@code /api/v2/traces/{traceId}} response, as the OTLP JSON the datasource returned.
+ */
+@RequiredArgsConstructor
+@Data
+public class DebuggingOTLPQueryTraceRsp {
+    private final JsonNode trace;
+    private final DebuggingTraceRsp debuggingTrace;
 }
-
-enum Source {
-    SKYWALKING = 0;
-    SKYWALKING_EVENT = 1;
-    ZIPKIN = 2;
-    ZIPKIN_EVENT = 3;
-}
\ No newline at end of file
diff --git a/oap-server/server-core/src/main/proto/SpanWrapper.proto b/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/trace/otlp/DebuggingOTLPQueryTracesRsp.java
similarity index 60%
copy from oap-server/server-core/src/main/proto/SpanWrapper.proto
copy to oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/trace/otlp/DebuggingOTLPQueryTracesRsp.java
index 823e3bebd5..c1a63cd400 100644
--- a/oap-server/server-core/src/main/proto/SpanWrapper.proto
+++ b/oap-server/server-admin/status/src/main/java/org/apache/skywalking/oap/server/admin/status/trace/otlp/DebuggingOTLPQueryTracesRsp.java
@@ -16,21 +16,19 @@
  *
  */
 
-syntax = "proto3";
+package org.apache.skywalking.oap.server.admin.status.trace.otlp;
 
-package org.apache.skywalking.oap.server.core.storage.query;
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.apache.skywalking.oap.server.admin.status.DebuggingTraceRsp;
 
-option java_package = "org.apache.skywalking.oap.server.core.storage.query.proto";
-option java_multiple_files = true;
-
-message SpanWrapper {
-    bytes span = 1;
-    Source source = 2;
+/**
+ * The {@code traces} array of a TraceQL {@code /api/search} response, as the JSON the datasource returned.
+ */
+@RequiredArgsConstructor
+@Data
+public class DebuggingOTLPQueryTracesRsp {
+    private final JsonNode traces;
+    private final DebuggingTraceRsp debuggingTrace;
 }
-
-enum Source {
-    SKYWALKING = 0;
-    SKYWALKING_EVENT = 1;
-    ZIPKIN = 2;
-    ZIPKIN_EVENT = 3;
-}
\ No newline at end of file
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/searchtag/TagType.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/searchtag/TagType.java
index 1eeceba8d9..f946aa4dc6 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/searchtag/TagType.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/searchtag/TagType.java
@@ -22,5 +22,6 @@ public enum TagType {
     TRACE,
     LOG,
     ZIPKIN,
-    ALARM
+    ALARM,
+    OTLP
 }
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceRelationTraffic.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceRelationTraffic.java
new file mode 100644
index 0000000000..609173298c
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceRelationTraffic.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.analysis.MetricsExtension;
+import org.apache.skywalking.oap.server.core.analysis.Stream;
+import org.apache.skywalking.oap.server.core.analysis.metrics.Metrics;
+import org.apache.skywalking.oap.server.core.analysis.worker.MetricsStreamProcessor;
+import org.apache.skywalking.oap.server.core.remote.grpc.proto.RemoteData;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.storage.StorageID;
+import org.apache.skywalking.oap.server.core.storage.annotation.BanyanDB;
+import org.apache.skywalking.oap.server.core.storage.annotation.Column;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Entity;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Storage;
+import org.apache.skywalking.oap.server.core.storage.type.StorageBuilder;
+
+/**
+ * The remote-service catalog of natively stored OTLP spans, one row per service and {@code peer.service} value.
+ * Feeds the TraceQL {@code resource.remote.service} value list.
+ */
+@Stream(name = OTLPServiceRelationTraffic.INDEX_NAME, scopeId = DefaultScopeDefine.OTLP_SERVICE_RELATION,
+    builder = OTLPServiceRelationTraffic.Builder.class, processor = MetricsStreamProcessor.class)
+@MetricsExtension(supportDownSampling = false, supportUpdate = false)
+@EqualsAndHashCode(of = {
+    "serviceName",
+    "peerService"
+})
+@BanyanDB.IndexMode
+public class OTLPServiceRelationTraffic extends Metrics {
+    public static final String INDEX_NAME = "otlp_service_relation_traffic";
+    public static final String SERVICE_NAME = "service_name";
+    public static final String PEER_SERVICE = "peer_service";
+
+    @Setter
+    @Getter
+    @Column(name = SERVICE_NAME)
+    private String serviceName;
+    @Setter
+    @Getter
+    @Column(name = PEER_SERVICE)
+    private String peerService;
+
+    @Override
+    protected StorageID id0() {
+        return new StorageID().append(serviceName).append(peerService);
+    }
+
+    @Override
+    public boolean combine(final Metrics metrics) {
+        return true;
+    }
+
+    @Override
+    public void calculate() {
+    }
+
+    @Override
+    public Metrics toHour() {
+        return null;
+    }
+
+    @Override
+    public Metrics toDay() {
+        return null;
+    }
+
+    @Override
+    public int remoteHashCode() {
+        return hashCode();
+    }
+
+    @Override
+    public void deserialize(final RemoteData remoteData) {
+        setServiceName(remoteData.getDataStrings(0));
+        setPeerService(remoteData.getDataStrings(1));
+        setTimeBucket(remoteData.getDataLongs(0));
+    }
+
+    @Override
+    public RemoteData.Builder serialize() {
+        final RemoteData.Builder remoteBuilder = RemoteData.newBuilder();
+        remoteBuilder.addDataStrings(getServiceName());
+        remoteBuilder.addDataStrings(getPeerService());
+        remoteBuilder.addDataLongs(getTimeBucket());
+        return remoteBuilder;
+    }
+
+    public static class Builder implements StorageBuilder<OTLPServiceRelationTraffic> {
+        @Override
+        public OTLPServiceRelationTraffic storage2Entity(final Convert2Entity converter) {
+            final OTLPServiceRelationTraffic traffic = new OTLPServiceRelationTraffic();
+            traffic.setServiceName((String) converter.get(SERVICE_NAME));
+            traffic.setPeerService((String) converter.get(PEER_SERVICE));
+            traffic.setTimeBucket(((Number) converter.get(TIME_BUCKET)).longValue());
+            return traffic;
+        }
+
+        @Override
+        public void entity2Storage(final OTLPServiceRelationTraffic storageData, final Convert2Storage converter) {
+            converter.accept(TIME_BUCKET, storageData.getTimeBucket());
+            converter.accept(SERVICE_NAME, storageData.getServiceName());
+            converter.accept(PEER_SERVICE, storageData.getPeerService());
+        }
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceSpanTraffic.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceSpanTraffic.java
new file mode 100644
index 0000000000..90d0ae1c9d
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceSpanTraffic.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.Const;
+import org.apache.skywalking.oap.server.core.analysis.MetricsExtension;
+import org.apache.skywalking.oap.server.core.analysis.Stream;
+import org.apache.skywalking.oap.server.core.analysis.metrics.Metrics;
+import org.apache.skywalking.oap.server.core.analysis.worker.MetricsStreamProcessor;
+import org.apache.skywalking.oap.server.core.remote.grpc.proto.RemoteData;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.storage.StorageID;
+import org.apache.skywalking.oap.server.core.storage.annotation.BanyanDB;
+import org.apache.skywalking.oap.server.core.storage.annotation.Column;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Entity;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Storage;
+import org.apache.skywalking.oap.server.core.storage.type.StorageBuilder;
+
+/**
+ * The span-name catalog of natively stored OTLP spans, one row per service and span name. Feeds the TraceQL
+ * {@code name} value list.
+ */
+@Stream(name = OTLPServiceSpanTraffic.INDEX_NAME, scopeId = DefaultScopeDefine.OTLP_SERVICE_SPAN,
+    builder = OTLPServiceSpanTraffic.Builder.class, processor = MetricsStreamProcessor.class)
+@MetricsExtension(supportDownSampling = false, supportUpdate = false)
+@EqualsAndHashCode(of = {
+    "serviceName",
+    "spanName"
+})
+@BanyanDB.IndexMode
+public class OTLPServiceSpanTraffic extends Metrics {
+    public static final String INDEX_NAME = "otlp_service_span_traffic";
+    public static final String SERVICE_NAME = "service_name";
+    public static final String SPAN_NAME = "span_name";
+
+    @Setter
+    @Getter
+    @Column(name = SERVICE_NAME)
+    private String serviceName;
+    @Setter
+    @Getter
+    @Column(name = SPAN_NAME)
+    private String spanName = Const.EMPTY_STRING;
+
+    @Override
+    protected StorageID id0() {
+        return new StorageID().append(serviceName).append(SPAN_NAME, spanName);
+    }
+
+    @Override
+    public boolean combine(final Metrics metrics) {
+        return true;
+    }
+
+    @Override
+    public void calculate() {
+    }
+
+    @Override
+    public Metrics toHour() {
+        return null;
+    }
+
+    @Override
+    public Metrics toDay() {
+        return null;
+    }
+
+    @Override
+    public int remoteHashCode() {
+        return hashCode();
+    }
+
+    @Override
+    public void deserialize(final RemoteData remoteData) {
+        setTimeBucket(remoteData.getDataLongs(0));
+        setServiceName(remoteData.getDataStrings(0));
+        setSpanName(remoteData.getDataStrings(1));
+    }
+
+    @Override
+    public RemoteData.Builder serialize() {
+        final RemoteData.Builder remoteBuilder = RemoteData.newBuilder();
+        remoteBuilder.addDataLongs(getTimeBucket());
+        remoteBuilder.addDataStrings(serviceName);
+        remoteBuilder.addDataStrings(spanName);
+        return remoteBuilder;
+    }
+
+    public static class Builder implements StorageBuilder<OTLPServiceSpanTraffic> {
+        @Override
+        public OTLPServiceSpanTraffic storage2Entity(final Convert2Entity converter) {
+            final OTLPServiceSpanTraffic traffic = new OTLPServiceSpanTraffic();
+            traffic.setServiceName((String) converter.get(SERVICE_NAME));
+            traffic.setSpanName((String) converter.get(SPAN_NAME));
+            traffic.setTimeBucket(((Number) converter.get(TIME_BUCKET)).longValue());
+            return traffic;
+        }
+
+        @Override
+        public void entity2Storage(final OTLPServiceSpanTraffic storageData, final Convert2Storage converter) {
+            converter.accept(SERVICE_NAME, storageData.getServiceName());
+            converter.accept(SPAN_NAME, storageData.getSpanName());
+            converter.accept(TIME_BUCKET, storageData.getTimeBucket());
+        }
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceTraffic.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceTraffic.java
new file mode 100644
index 0000000000..26f0a2f921
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPServiceTraffic.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.Const;
+import org.apache.skywalking.oap.server.core.analysis.MetricsExtension;
+import org.apache.skywalking.oap.server.core.analysis.Stream;
+import org.apache.skywalking.oap.server.core.analysis.metrics.Metrics;
+import org.apache.skywalking.oap.server.core.analysis.worker.MetricsStreamProcessor;
+import org.apache.skywalking.oap.server.core.remote.grpc.proto.RemoteData;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.storage.StorageID;
+import org.apache.skywalking.oap.server.core.storage.annotation.BanyanDB;
+import org.apache.skywalking.oap.server.core.storage.annotation.Column;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Entity;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Storage;
+import org.apache.skywalking.oap.server.core.storage.type.StorageBuilder;
+
+/**
+ * The service-name catalog of natively stored OTLP spans, one row per {@code resource.service.name}. Feeds the
+ * TraceQL {@code resource.service.name} value list.
+ */
+@Stream(name = OTLPServiceTraffic.INDEX_NAME, scopeId = DefaultScopeDefine.OTLP_SERVICE,
+    builder = OTLPServiceTraffic.Builder.class, processor = MetricsStreamProcessor.class)
+@MetricsExtension(supportDownSampling = false, supportUpdate = false)
+@EqualsAndHashCode(of = {
+    "serviceName"
+})
+@BanyanDB.IndexMode
+public class OTLPServiceTraffic extends Metrics {
+    public static final String INDEX_NAME = "otlp_service_traffic";
+    public static final String SERVICE_NAME = "service_name";
+
+    @Setter
+    @Getter
+    @Column(name = SERVICE_NAME)
+    private String serviceName = Const.EMPTY_STRING;
+
+    @Override
+    protected StorageID id0() {
+        return new StorageID().append(SERVICE_NAME, serviceName);
+    }
+
+    @Override
+    public boolean combine(final Metrics metrics) {
+        return true;
+    }
+
+    @Override
+    public void calculate() {
+    }
+
+    @Override
+    public Metrics toHour() {
+        return null;
+    }
+
+    @Override
+    public Metrics toDay() {
+        return null;
+    }
+
+    @Override
+    public int remoteHashCode() {
+        return hashCode();
+    }
+
+    @Override
+    public void deserialize(final RemoteData remoteData) {
+        setServiceName(remoteData.getDataStrings(0));
+        setTimeBucket(remoteData.getDataLongs(0));
+    }
+
+    @Override
+    public RemoteData.Builder serialize() {
+        final RemoteData.Builder remoteBuilder = RemoteData.newBuilder();
+        remoteBuilder.addDataStrings(getServiceName());
+        remoteBuilder.addDataLongs(getTimeBucket());
+        return remoteBuilder;
+    }
+
+    public static class Builder implements StorageBuilder<OTLPServiceTraffic> {
+        @Override
+        public OTLPServiceTraffic storage2Entity(final Convert2Entity converter) {
+            final OTLPServiceTraffic traffic = new OTLPServiceTraffic();
+            traffic.setServiceName((String) converter.get(SERVICE_NAME));
+            traffic.setTimeBucket(((Number) converter.get(TIME_BUCKET)).longValue());
+            return traffic;
+        }
+
+        @Override
+        public void entity2Storage(final OTLPServiceTraffic storageData, final Convert2Storage converter) {
+            converter.accept(SERVICE_NAME, storageData.getServiceName());
+            converter.accept(TIME_BUCKET, storageData.getTimeBucket());
+        }
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPSpanRecord.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPSpanRecord.java
new file mode 100644
index 0000000000..f9c2cdc901
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/OTLPSpanRecord.java
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp;
+
+import com.google.protobuf.ByteString;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.analysis.Stream;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.analysis.record.Record;
+import org.apache.skywalking.oap.server.core.analysis.worker.RecordStreamProcessor;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.storage.StorageID;
+import org.apache.skywalking.oap.server.core.storage.annotation.BanyanDB;
+import org.apache.skywalking.oap.server.core.storage.annotation.Column;
+import org.apache.skywalking.oap.server.core.storage.annotation.ElasticSearch;
+import org.apache.skywalking.oap.server.core.storage.annotation.SQLDatabase;
+import org.apache.skywalking.oap.server.core.storage.annotation.SuperDataset;
+import org.apache.skywalking.oap.server.core.storage.model.BanyanDBTrace;
+import org.apache.skywalking.oap.server.core.storage.query.proto.Source;
+import org.apache.skywalking.oap.server.core.storage.query.proto.SpanWrapper;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Entity;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Storage;
+import org.apache.skywalking.oap.server.core.storage.type.StorageBuilder;
+
+import static org.apache.skywalking.oap.server.core.storage.StorageData.TIME_BUCKET;
+
+/**
+ * One OTLP span stored natively: the columns are the search index, {@link #dataBinary} is a single-span
+ * {@code ResourceSpans} message holding the resource, the scope and the span exactly as the SDK sent them.
+ * The read path serves {@code dataBinary}; the columns are never converted back into a span, so no field of
+ * the wire message is lost the way the Zipkin conversion loses attribute types, events and links.
+ *
+ * <p>{@code server-core} keeps no dependency on the OTLP protobuf classes: the record carries {@code byte[]}
+ * and decoding happens in the query plugin, the same split {@code OTLPSpanReader} enforces.
+ */
+@SuperDataset
+@Stream(name = OTLPSpanRecord.INDEX_NAME, scopeId = DefaultScopeDefine.OTLP_SPAN,
+    builder = OTLPSpanRecord.Builder.class, processor = RecordStreamProcessor.class)
+@SQLDatabase.ExtraColumn4AdditionalEntity(additionalTable = OTLPSpanRecord.ADDITIONAL_TAG_TABLE, parentColumn = TIME_BUCKET)
+@BanyanDB.TimestampColumn(OTLPSpanRecord.START_TIME)
+@BanyanDB.Trace.TraceIdColumn(OTLPSpanRecord.TRACE_ID)
+@BanyanDB.Trace.SpanIdColumn(OTLPSpanRecord.SPAN_ID)
+// The BanyanDB trace model answers an equality on an int tag only through an index rule; string tags are
+// scanned. kind and status_code are the int filters TraceQL exposes, so both sit in every rule.
+@BanyanDB.Trace.IndexRule(name = OTLPSpanRecord.START_TIME, columns = {
+    OTLPSpanRecord.SERVICE_NAME,
+    OTLPSpanRecord.STATUS_CODE,
+    OTLPSpanRecord.KIND,
+}, orderByColumn = OTLPSpanRecord.START_TIME)
+@BanyanDB.Trace.IndexRule(name = OTLPSpanRecord.DURATION, columns = {
+    OTLPSpanRecord.SERVICE_NAME,
+    OTLPSpanRecord.STATUS_CODE,
+    OTLPSpanRecord.KIND,
+}, orderByColumn = OTLPSpanRecord.DURATION)
+@BanyanDB.Group(traceGroup = BanyanDB.TraceGroup.OTLP_TRACE)
+public class OTLPSpanRecord extends Record implements BanyanDBTrace {
+    public static final String INDEX_NAME = "otlp_span";
+    public static final String ADDITIONAL_TAG_TABLE = "otlp_span_tag";
+    public static final String TRACE_ID = "trace_id";
+    public static final String SPAN_ID = "span_id";
+    public static final String PARENT_SPAN_ID = "parent_span_id";
+    public static final String SERVICE_NAME = "service_name";
+    public static final String SERVICE_INSTANCE = "service_instance";
+    public static final String SCOPE_NAME = "scope_name";
+    public static final String NAME = "name";
+    public static final String KIND = "kind";
+    public static final String STATUS_CODE = "status_code";
+    public static final String PEER_SERVICE = "peer_service";
+    public static final String START_TIME = "start_time";
+    public static final String DURATION = "duration";
+    public static final String TAGS = "tags";
+    /**
+     * The prefixes of the {@link #tags} entries, so a resource attribute and a span attribute with the same key stay
+     * apart in the index, the way TraceQL's {@code resource.} and {@code span.} scopes tell them apart.
+     */
+    public static final String RESOURCE_TAG_PREFIX = "resource.";
+    public static final String SPAN_TAG_PREFIX = "span.";
+    public static final String DATA_BINARY = "data_binary";
+    /**
+     * Resource attribute keys {@link #SERVICE_NAME} is resolved from, first present wins. The receiver fills the
+     * column with it and the TraceQL datasource re-resolves it the same way when it matches spans in memory.
+     */
+    public static final List<String> SERVICE_NAME_RESOURCE_KEYS = List.of(
+        "service.name", "faas.name", "k8s.deployment.name", "process.executable.name");
+    /**
+     * Resource attribute keys {@link #SERVICE_INSTANCE} is resolved from, first present wins.
+     */
+    public static final List<String> SERVICE_INSTANCE_RESOURCE_KEYS = List.of("service.instance.id", "service.instance");
+    /**
+     * The span attribute {@link #PEER_SERVICE} is copied from, per the OpenTelemetry semantic conventions.
+     */
+    public static final String PEER_SERVICE_ATTRIBUTE = "peer.service";
+
+    /**
+     * The {@link #TAGS} entries a TraceQL attribute condition matches: the one scoped form when the key carries its
+     * scope ({@code resource.env}, {@code span.env}), both forms for an unscoped key, which TraceQL defines as
+     * either scope. Every storage and the in-memory matcher go through this, so they agree.
+     */
+    public static List<String> indexedTags(final String key, final String value) {
+        if (key.startsWith(RESOURCE_TAG_PREFIX) || key.startsWith(SPAN_TAG_PREFIX)) {
+            return List.of(key + "=" + value);
+        }
+        return List.of(RESOURCE_TAG_PREFIX + key + "=" + value, SPAN_TAG_PREFIX + key + "=" + value);
+    }
+
+    /**
+     * 32 lowercase hex characters.
+     */
+    @Setter
+    @Getter
+    @Column(name = TRACE_ID)
+    @SQLDatabase.AdditionalEntity(additionalTables = {ADDITIONAL_TAG_TABLE}, reserveOriginalColumns = true)
+    @ElasticSearch.Routing
+    @ElasticSearch.EnableDocValues
+    private String traceId;
+    /**
+     * 16 lowercase hex characters.
+     */
+    @Setter
+    @Getter
+    @Column(name = SPAN_ID)
+    private String spanId;
+    /**
+     * Lowercase hex, empty for a root span.
+     */
+    @Setter
+    @Getter
+    @Column(name = PARENT_SPAN_ID, storageOnly = true)
+    private String parentSpanId;
+    @Setter
+    @Getter
+    @Column(name = SERVICE_NAME)
+    private String serviceName;
+    @Setter
+    @Getter
+    @Column(name = SERVICE_INSTANCE)
+    private String serviceInstance;
+    @Setter
+    @Getter
+    @Column(name = SCOPE_NAME)
+    private String scopeName;
+    @Setter
+    @Getter
+    @Column(name = NAME)
+    private String name;
+    /**
+     * The OTLP {@code SpanKind} enum number, 0 UNSPECIFIED to 5 CONSUMER.
+     */
+    @Setter
+    @Getter
+    @Column(name = KIND)
+    private int kind;
+    /**
+     * The OTLP {@code StatusCode} enum number, 0 UNSET, 1 OK, 2 ERROR.
+     */
+    @Setter
+    @Getter
+    @Column(name = STATUS_CODE)
+    private int statusCode;
+    @Setter
+    @Getter
+    @Column(name = PEER_SERVICE)
+    private String peerService;
+    /**
+     * Milliseconds since the epoch, from {@code start_time_unix_nano}.
+     */
+    @Setter
+    @Getter
+    @ElasticSearch.EnableDocValues
+    @Column(name = START_TIME)
+    private long startTime;
+    /**
+     * Nanoseconds, {@code end_time_unix_nano - start_time_unix_nano}.
+     */
+    @Setter
+    @Getter
+    @ElasticSearch.EnableDocValues
+    @BanyanDB.EnableSort
+    @Column(name = DURATION)
+    private long duration;
+    /**
+     * {@code resource.key=value} of every resource attribute and {@code span.key=value} of every span attribute, the
+     * equality-search index; {@link #indexedTags} gives the forms a query matches. The typed values stay in
+     * {@link #dataBinary}.
+     */
+    @Setter
+    @Getter
+    @Column(name = TAGS, indexOnly = true, length = Tag.TAG_LENGTH)
+    @SQLDatabase.AdditionalEntity(additionalTables = {ADDITIONAL_TAG_TABLE})
+    private List<String> tags;
+    /**
+     * A serialized single-span {@code ResourceSpans}.
+     */
+    @Setter
+    @Getter
+    @Column(name = DATA_BINARY, storageOnly = true)
+    private byte[] dataBinary;
+
+    @Override
+    public StorageID id() {
+        return new StorageID().append(TRACE_ID, traceId).append(SPAN_ID, spanId);
+    }
+
+    @Override
+    public SpanWrapper getSpanWrapper() {
+        return SpanWrapper.newBuilder()
+                          .setSpan(ByteString.copyFrom(dataBinary))
+                          .setSource(Source.OTLP)
+                          .build();
+    }
+
+    public static class Builder implements StorageBuilder<OTLPSpanRecord> {
+        @Override
+        public OTLPSpanRecord storage2Entity(final Convert2Entity converter) {
+            final OTLPSpanRecord record = new OTLPSpanRecord();
+            record.setTraceId((String) converter.get(TRACE_ID));
+            record.setSpanId((String) converter.get(SPAN_ID));
+            record.setParentSpanId((String) converter.get(PARENT_SPAN_ID));
+            record.setServiceName((String) converter.get(SERVICE_NAME));
+            record.setServiceInstance((String) converter.get(SERVICE_INSTANCE));
+            record.setScopeName((String) converter.get(SCOPE_NAME));
+            record.setName((String) converter.get(NAME));
+            if (converter.get(KIND) != null) {
+                record.setKind(((Number) converter.get(KIND)).intValue());
+            }
+            if (converter.get(STATUS_CODE) != null) {
+                record.setStatusCode(((Number) converter.get(STATUS_CODE)).intValue());
+            }
+            record.setPeerService((String) converter.get(PEER_SERVICE));
+            record.setStartTime(((Number) converter.get(START_TIME)).longValue());
+            record.setDuration(((Number) converter.get(DURATION)).longValue());
+            record.setTimeBucket(((Number) converter.get(TIME_BUCKET)).longValue());
+            record.setDataBinary(converter.getBytes(DATA_BINARY));
+            // tags are the index only: every attribute is inside dataBinary already.
+            return record;
+        }
+
+        @Override
+        public void entity2Storage(final OTLPSpanRecord storageData, final Convert2Storage converter) {
+            converter.accept(TRACE_ID, storageData.getTraceId());
+            converter.accept(SPAN_ID, storageData.getSpanId());
+            converter.accept(PARENT_SPAN_ID, storageData.getParentSpanId());
+            converter.accept(SERVICE_NAME, storageData.getServiceName());
+            converter.accept(SERVICE_INSTANCE, storageData.getServiceInstance());
+            converter.accept(SCOPE_NAME, storageData.getScopeName());
+            converter.accept(NAME, storageData.getName());
+            converter.accept(KIND, storageData.getKind());
+            converter.accept(STATUS_CODE, storageData.getStatusCode());
+            converter.accept(PEER_SERVICE, storageData.getPeerService());
+            converter.accept(START_TIME, storageData.getStartTime());
+            converter.accept(DURATION, storageData.getDuration());
+            converter.accept(TIME_BUCKET, storageData.getTimeBucket());
+            converter.accept(TAGS, storageData.getTags());
+            converter.accept(DATA_BINARY, storageData.getDataBinary());
+        }
+    }
+}
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverConfig.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceDispatcher.java
similarity index 52%
copy from oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverConfig.java
copy to oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceDispatcher.java
index 40da30faf0..eb60122836 100644
--- a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverConfig.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceDispatcher.java
@@ -16,27 +16,19 @@
  *
  */
 
-package org.apache.skywalking.oap.server.receiver.otel;
+package org.apache.skywalking.oap.server.core.otlp.dispatcher;
 
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import lombok.Getter;
-import lombok.Setter;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.skywalking.oap.server.library.module.ModuleConfig;
+import org.apache.skywalking.oap.server.core.analysis.SourceDispatcher;
+import org.apache.skywalking.oap.server.core.analysis.worker.MetricsStreamProcessor;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceTraffic;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPService;
 
-import java.util.List;
-
-@Slf4j
-public class OtelMetricReceiverConfig extends ModuleConfig {
-
-    @Setter
-    private String enabledHandlers;
-
-    @Getter
-    private String enabledOtelMetricsRules;
-
-    public List<String> getEnabledHandlers() {
-        return Splitter.on(",").trimResults().omitEmptyStrings().splitToList(Strings.nullToEmpty(enabledHandlers));
+public class OTLPServiceDispatcher implements SourceDispatcher<OTLPService> {
+    @Override
+    public void dispatch(final OTLPService source) {
+        final OTLPServiceTraffic traffic = new OTLPServiceTraffic();
+        traffic.setServiceName(source.getServiceName());
+        traffic.setTimeBucket(source.getTimeBucket());
+        MetricsStreamProcessor.getInstance().in(traffic);
     }
 }
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceRelationDispatcher.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceRelationDispatcher.java
new file mode 100644
index 0000000000..7f2f708192
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceRelationDispatcher.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp.dispatcher;
+
+import org.apache.skywalking.oap.server.core.analysis.SourceDispatcher;
+import org.apache.skywalking.oap.server.core.analysis.worker.MetricsStreamProcessor;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceRelationTraffic;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPServiceRelation;
+
+public class OTLPServiceRelationDispatcher implements SourceDispatcher<OTLPServiceRelation> {
+    @Override
+    public void dispatch(final OTLPServiceRelation source) {
+        final OTLPServiceRelationTraffic traffic = new OTLPServiceRelationTraffic();
+        traffic.setServiceName(source.getServiceName());
+        traffic.setPeerService(source.getPeerService());
+        traffic.setTimeBucket(source.getTimeBucket());
+        MetricsStreamProcessor.getInstance().in(traffic);
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceSpanDispatcher.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceSpanDispatcher.java
new file mode 100644
index 0000000000..d34fbbe1d9
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPServiceSpanDispatcher.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp.dispatcher;
+
+import org.apache.skywalking.oap.server.core.analysis.SourceDispatcher;
+import org.apache.skywalking.oap.server.core.analysis.worker.MetricsStreamProcessor;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceSpanTraffic;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPServiceSpan;
+
+public class OTLPServiceSpanDispatcher implements SourceDispatcher<OTLPServiceSpan> {
+    @Override
+    public void dispatch(final OTLPServiceSpan source) {
+        final OTLPServiceSpanTraffic traffic = new OTLPServiceSpanTraffic();
+        traffic.setServiceName(source.getServiceName());
+        traffic.setSpanName(source.getSpanName());
+        traffic.setTimeBucket(source.getTimeBucket());
+        MetricsStreamProcessor.getInstance().in(traffic);
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPSpanRecordDispatcher.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPSpanRecordDispatcher.java
new file mode 100644
index 0000000000..a2693c559a
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/dispatcher/OTLPSpanRecordDispatcher.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp.dispatcher;
+
+import org.apache.skywalking.oap.server.core.analysis.SourceDispatcher;
+import org.apache.skywalking.oap.server.core.analysis.worker.RecordStreamProcessor;
+import org.apache.skywalking.oap.server.core.otlp.OTLPSpanRecord;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPSpan;
+
+public class OTLPSpanRecordDispatcher implements SourceDispatcher<OTLPSpan> {
+    @Override
+    public void dispatch(final OTLPSpan source) {
+        final OTLPSpanRecord record = new OTLPSpanRecord();
+        record.setTraceId(source.getTraceId());
+        record.setSpanId(source.getSpanId());
+        record.setParentSpanId(source.getParentSpanId());
+        record.setServiceName(source.getServiceName());
+        record.setServiceInstance(source.getServiceInstance());
+        record.setScopeName(source.getScopeName());
+        record.setName(source.getName());
+        record.setKind(source.getKind());
+        record.setStatusCode(source.getStatusCode());
+        record.setPeerService(source.getPeerService());
+        record.setStartTime(source.getStartTime());
+        record.setDuration(source.getDuration());
+        record.setTags(source.getTags());
+        record.setDataBinary(source.getDataBinary());
+        record.setTimeBucket(source.getTimeBucket());
+        RecordStreamProcessor.getInstance().in(record);
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPService.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPService.java
new file mode 100644
index 0000000000..cbb37bbbb2
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPService.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp.source;
+
+import com.google.gson.JsonObject;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.source.ScopeDeclaration;
+import org.apache.skywalking.oap.server.core.source.Source;
+
+@ScopeDeclaration(id = DefaultScopeDefine.OTLP_SERVICE, name = "OTLPService")
+public class OTLPService extends Source {
+    @Override
+    public int scope() {
+        return DefaultScopeDefine.OTLP_SERVICE;
+    }
+
+    @Override
+    public String getEntityId() {
+        return serviceName;
+    }
+
+    @Setter
+    @Getter
+    private String serviceName;
+
+    @Override
+    public String toJson() {
+        final JsonObject obj = new JsonObject();
+        obj.addProperty("scope", scope());
+        obj.addProperty("entityId", getEntityId());
+        obj.addProperty("timeBucket", getTimeBucket());
+        obj.addProperty("serviceName", serviceName);
+        return obj.toString();
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPServiceRelation.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPServiceRelation.java
new file mode 100644
index 0000000000..411d41f5a0
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPServiceRelation.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp.source;
+
+import com.google.gson.JsonObject;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.Const;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.source.ScopeDeclaration;
+import org.apache.skywalking.oap.server.core.source.Source;
+
+/**
+ * A service and the {@code peer.service} one of its spans called.
+ */
+@ScopeDeclaration(id = DefaultScopeDefine.OTLP_SERVICE_RELATION, name = "OTLPServiceRelation")
+public class OTLPServiceRelation extends Source {
+    @Override
+    public int scope() {
+        return DefaultScopeDefine.OTLP_SERVICE_RELATION;
+    }
+
+    @Override
+    public String getEntityId() {
+        return serviceName + Const.ID_CONNECTOR + peerService;
+    }
+
+    @Setter
+    @Getter
+    private String serviceName;
+    @Setter
+    @Getter
+    private String peerService;
+
+    @Override
+    public String toJson() {
+        final JsonObject obj = new JsonObject();
+        obj.addProperty("scope", scope());
+        obj.addProperty("entityId", getEntityId());
+        obj.addProperty("timeBucket", getTimeBucket());
+        obj.addProperty("serviceName", serviceName);
+        obj.addProperty("peerService", peerService);
+        return obj.toString();
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPServiceSpan.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPServiceSpan.java
new file mode 100644
index 0000000000..26fd10fbe6
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPServiceSpan.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp.source;
+
+import com.google.gson.JsonObject;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.Const;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.source.ScopeDeclaration;
+import org.apache.skywalking.oap.server.core.source.Source;
+
+@ScopeDeclaration(id = DefaultScopeDefine.OTLP_SERVICE_SPAN, name = "OTLPServiceSpan")
+public class OTLPServiceSpan extends Source {
+    @Override
+    public int scope() {
+        return DefaultScopeDefine.OTLP_SERVICE_SPAN;
+    }
+
+    @Override
+    public String getEntityId() {
+        return serviceName + Const.ID_CONNECTOR + spanName;
+    }
+
+    @Setter
+    @Getter
+    private String serviceName;
+    @Setter
+    @Getter
+    private String spanName;
+
+    @Override
+    public String toJson() {
+        final JsonObject obj = new JsonObject();
+        obj.addProperty("scope", scope());
+        obj.addProperty("entityId", getEntityId());
+        obj.addProperty("timeBucket", getTimeBucket());
+        obj.addProperty("serviceName", serviceName);
+        obj.addProperty("spanName", spanName);
+        return obj.toString();
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPSpan.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPSpan.java
new file mode 100644
index 0000000000..8a8076eaa8
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/otlp/source/OTLPSpan.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.otlp.source;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.source.ScopeDeclaration;
+import org.apache.skywalking.oap.server.core.source.Source;
+
+/**
+ * One OTLP span on its way to {@code otlp_span}. The fields are the index columns; {@link #dataBinary} is the
+ * single-span {@code ResourceSpans} message that is stored verbatim.
+ */
+@ScopeDeclaration(id = DefaultScopeDefine.OTLP_SPAN, name = "OTLPSpan")
+public class OTLPSpan extends Source {
+    @Override
+    public int scope() {
+        return DefaultScopeDefine.OTLP_SPAN;
+    }
+
+    @Override
+    public String getEntityId() {
+        return spanId;
+    }
+
+    @Setter
+    @Getter
+    private String traceId;
+    @Setter
+    @Getter
+    private String spanId;
+    @Setter
+    @Getter
+    private String parentSpanId;
+    @Setter
+    @Getter
+    private String serviceName;
+    @Setter
+    @Getter
+    private String serviceInstance;
+    @Setter
+    @Getter
+    private String scopeName;
+    @Setter
+    @Getter
+    private String name;
+    @Setter
+    @Getter
+    private int kind;
+    @Setter
+    @Getter
+    private int statusCode;
+    @Setter
+    @Getter
+    private String peerService;
+    @Setter
+    @Getter
+    private long startTime;
+    @Setter
+    @Getter
+    private long duration;
+    @Setter
+    @Getter
+    private List<String> tags = new ArrayList<>();
+    @Setter
+    @Getter
+    private byte[] dataBinary;
+
+    @Override
+    public String toJson() {
+        final JsonObject obj = new JsonObject();
+        obj.addProperty("scope", scope());
+        obj.addProperty("entityId", getEntityId());
+        obj.addProperty("timeBucket", getTimeBucket());
+        obj.addProperty("traceId", traceId);
+        obj.addProperty("spanId", spanId);
+        obj.addProperty("parentSpanId", parentSpanId);
+        obj.addProperty("serviceName", serviceName);
+        obj.addProperty("serviceInstance", serviceInstance);
+        obj.addProperty("scopeName", scopeName);
+        obj.addProperty("name", name);
+        obj.addProperty("kind", kind);
+        obj.addProperty("statusCode", statusCode);
+        obj.addProperty("peerService", peerService);
+        obj.addProperty("startTime", startTime);
+        obj.addProperty("duration", duration);
+        final JsonArray tagArray = new JsonArray();
+        if (tags != null) {
+            tags.forEach(tagArray::add);
+        }
+        obj.add("tags", tagArray);
+        obj.addProperty("dataBinaryLength", dataBinary == null ? 0 : dataBinary.length);
+        return obj.toString();
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/OTLPTraceQueryService.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/OTLPTraceQueryService.java
new file mode 100644
index 0000000000..693d75d429
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/OTLPTraceQueryService.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.query;
+
+import java.io.IOException;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.input.OTLPTraceQueryCondition;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingSpan;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext;
+import org.apache.skywalking.oap.server.core.storage.StorageModule;
+import org.apache.skywalking.oap.server.core.storage.query.IOTLPTraceQueryDAO;
+import org.apache.skywalking.oap.server.core.storage.query.proto.SpanWrapper;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+import static org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext.TRACE_CONTEXT;
+
+/**
+ * Reads natively stored OTLP spans for the query plugins. Constructed by its callers, the way
+ * {@code ZipkinQueryService} is, rather than registered as a core service: it is a thin validation and
+ * debugging-trace layer over {@link IOTLPTraceQueryDAO} and decodes nothing, so the OTLP protobuf stays out of
+ * {@code server-core}.
+ */
+public class OTLPTraceQueryService {
+    private final ModuleManager moduleManager;
+    private IOTLPTraceQueryDAO queryDAO;
+
+    public OTLPTraceQueryService(final ModuleManager moduleManager) {
+        this.moduleManager = moduleManager;
+    }
+
+    private IOTLPTraceQueryDAO getQueryDAO() {
+        if (queryDAO == null) {
+            queryDAO = moduleManager.find(StorageModule.NAME).provider().getService(IOTLPTraceQueryDAO.class);
+        }
+        return queryDAO;
+    }
+
+    public List<String> getServiceNames(@Nullable final Duration duration) throws IOException {
+        return getQueryDAO().getServiceNames(duration);
+    }
+
+    public List<String> getSpanNames(final String serviceName, @Nullable final Duration duration) throws IOException {
+        return getQueryDAO().getSpanNames(serviceName, duration);
+    }
+
+    public List<String> getPeerServiceNames(final String serviceName,
+                                            @Nullable final Duration duration) throws IOException {
+        return getQueryDAO().getPeerServiceNames(serviceName, duration);
+    }
+
+    /**
+     * @param traceId  32 lowercase hex characters
+     * @param duration nullable: the storage then searches everything it retains; a non-null one bounds the lookup
+     *                 and its {@code coldStage} flag targets the BanyanDB cold stage.
+     */
+    public List<SpanWrapper> queryTraceById(final String traceId, @Nullable final Duration duration) throws IOException {
+        final DebuggingTraceContext traceContext = TRACE_CONTEXT.get();
+        DebuggingSpan span = null;
+        try {
+            if (traceContext != null) {
+                span = traceContext.createSpan("Query Service: queryOTLPTraceById");
+                span.setMsg("Condition: TraceId: " + traceId + ", Duration: " + duration);
+            }
+            return getQueryDAO().queryTraceByIdDebuggable(traceId, duration);
+        } finally {
+            if (traceContext != null && span != null) {
+                traceContext.stopSpan(span);
+            }
+        }
+    }
+
+    /**
+     * @throws IllegalArgumentException when the condition carries neither a trace id nor a time range.
+     */
+    public List<List<SpanWrapper>> queryTraces(final OTLPTraceQueryCondition condition) throws IOException {
+        if (StringUtil.isEmpty(condition.getTraceId()) && condition.getQueryDuration() == null) {
+            throw new IllegalArgumentException("The OTLP trace query requires a traceId or a queryDuration.");
+        }
+        final DebuggingTraceContext traceContext = TRACE_CONTEXT.get();
+        DebuggingSpan span = null;
+        try {
+            if (traceContext != null) {
+                span = traceContext.createSpan("Query Service: queryOTLPTraces");
+                span.setMsg("Condition: " + condition);
+            }
+            return getQueryDAO().queryTracesDebuggable(condition);
+        } finally {
+            if (traceContext != null && span != null) {
+                traceContext.stopSpan(span);
+            }
+        }
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/TTLStatusQuery.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/TTLStatusQuery.java
index d23940221f..9ddd70e96c 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/TTLStatusQuery.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/TTLStatusQuery.java
@@ -53,7 +53,10 @@ public class TTLStatusQuery implements Service {
         if (ttlDefinition == null) {
             ttlDefinition = new TTLDefinition(
                 new MetricsTTL(coreMetricsDataTTL, coreMetricsDataTTL, coreMetricsDataTTL, coreMetricsDataTTL),
-                new RecordsTTL(coreRecordDataTTL, coreRecordDataTTL, coreRecordDataTTL, coreRecordDataTTL, coreRecordDataTTL)
+                new RecordsTTL(
+                    coreRecordDataTTL, coreRecordDataTTL, coreRecordDataTTL, coreRecordDataTTL, coreRecordDataTTL,
+                    coreRecordDataTTL
+                )
             );
         }
         return ttlDefinition;
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/input/OTLPTraceQueryCondition.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/input/OTLPTraceQueryCondition.java
new file mode 100644
index 0000000000..6ace051179
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/query/input/OTLPTraceQueryCondition.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.query.input;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.query.type.QueryOrder;
+
+/**
+ * The search condition over natively stored OTLP spans, {@code otlp_span}. Every field is optional except that
+ * either {@link #traceId} or {@link #queryDuration} must be set: a lookup by trace id may search everything the
+ * storage retains, any other search needs a time range.
+ *
+ * <p>The scalar fields are equality conditions on the record's index columns; {@link #tags} are equality
+ * conditions on the attribute index, where a key carrying its {@code resource.} or {@code span.} scope matches that
+ * scope only and a bare key matches either, see {@code OTLPSpanRecord#indexedTags}. Durations are nanoseconds, the
+ * unit of the {@code duration} column.
+ */
+@Getter
+@Setter
+@ToString
+public class OTLPTraceQueryCondition {
+    private String serviceName;
+    private String serviceInstance;
+    private String scopeName;
+    private String spanName;
+    private String peerService;
+    /**
+     * The OTLP {@code SpanKind} enum number, or null for any kind.
+     */
+    private Integer kind;
+    /**
+     * The OTLP {@code StatusCode} enum number, or null for any status.
+     */
+    private Integer statusCode;
+    /**
+     * Inclusive lower bound in nanoseconds, null for none. Zero is a real bound, so {@code duration <= 0} can be asked.
+     */
+    private Long minDurationNanos;
+    /**
+     * Inclusive upper bound in nanoseconds, null for none.
+     */
+    private Long maxDurationNanos;
+    private List<Tag> tags = new ArrayList<>();
+    private String traceId;
+    private Duration queryDuration;
+    private QueryOrder queryOrder = QueryOrder.BY_START_TIME;
+    /**
+     * The most traces to return.
+     */
+    private int limit = 20;
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/source/DefaultScopeDefine.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/source/DefaultScopeDefine.java
index 2cba81c864..61497924e6 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/source/DefaultScopeDefine.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/source/DefaultScopeDefine.java
@@ -162,6 +162,10 @@ public class DefaultScopeDefine {
     public static final int GEN_AI_EVALUATION_RECORD = 99;
     public static final int AI_AGENT_SESSION_DATA = 100;
     public static final int AI_AGENT_SESSION_FLOW = 101;
+    public static final int OTLP_SPAN = 102;
+    public static final int OTLP_SERVICE = 103;
+    public static final int OTLP_SERVICE_SPAN = 104;
+    public static final int OTLP_SERVICE_RELATION = 105;
 
     /**
      * Catalog of scope, the metrics processor could use this to group all generated metrics by oal rt.
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/StorageModule.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/StorageModule.java
index 423fe033da..5b9bdfc57c 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/StorageModule.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/StorageModule.java
@@ -51,6 +51,7 @@ import org.apache.skywalking.oap.server.core.storage.query.ITagAutoCompleteQuery
 import org.apache.skywalking.oap.server.core.storage.query.IRecordsQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.ITopologyQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.ITraceQueryDAO;
+import org.apache.skywalking.oap.server.core.storage.query.IOTLPTraceQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.IZipkinQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.ttl.StorageTTLStatusQuery;
 import org.apache.skywalking.oap.server.library.module.ModuleDefine;
@@ -98,6 +99,7 @@ public class StorageModule extends ModuleDefine {
             IServiceLabelDAO.class,
             ITagAutoCompleteQueryDAO.class,
             IZipkinQueryDAO.class,
+            IOTLPTraceQueryDAO.class,
             ISpanAttachedEventQueryDAO.class,
             IHierarchyQueryDAO.class,
             IAsyncProfilerTaskQueryDAO.class,
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/BanyanDB.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/BanyanDB.java
index ad08455aa4..0372dc8026 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/BanyanDB.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/BanyanDB.java
@@ -353,6 +353,7 @@ public @interface BanyanDB {
     enum TraceGroup {
         TRACE("trace"),
         ZIPKIN_TRACE("zipkinTrace"),
+        OTLP_TRACE("otlpTrace"),
         NONE("none");
 
         @Getter
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/IOTLPTraceQueryDAO.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/IOTLPTraceQueryDAO.java
new file mode 100644
index 0000000000..24e57b6224
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/IOTLPTraceQueryDAO.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.storage.query;
+
+import java.io.IOException;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.input.OTLPTraceQueryCondition;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingSpan;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext;
+import org.apache.skywalking.oap.server.core.storage.DAO;
+import org.apache.skywalking.oap.server.core.storage.query.proto.SpanWrapper;
+
+/**
+ * Reads natively stored OTLP spans, {@code otlp_span}, and their name catalogs. Every storage returns the spans
+ * as {@link SpanWrapper} carrying the stored single-span {@code ResourceSpans} bytes, so {@code server-core}
+ * never decodes the OTLP protobuf.
+ */
+public interface IOTLPTraceQueryDAO extends DAO {
+    /**
+     * @param duration nullable: the catalog is then read without a time bound.
+     */
+    List<String> getServiceNames(@Nullable Duration duration) throws IOException;
+
+    /**
+     * @param duration nullable: the catalog is then read without a time bound.
+     */
+    List<String> getSpanNames(String serviceName, @Nullable Duration duration) throws IOException;
+
+    /**
+     * @param duration nullable: the catalog is then read without a time bound.
+     */
+    List<String> getPeerServiceNames(String serviceName, @Nullable Duration duration) throws IOException;
+
+    /**
+     * @param duration nullable: the lookup then searches everything the storage retains; a non-null one bounds it to
+     *                 the spans that started in the range on Elasticsearch and JDBC, and to the day segments the
+     *                 range touches on BanyanDB, whose trace model resolves a trace id per segment. Its
+     *                 {@code coldStage} flag targets the BanyanDB cold stage instead of the hot and warm stages.
+     */
+    List<SpanWrapper> queryTraceById(String traceId, @Nullable Duration duration) throws IOException;
+
+    /**
+     * @return the spans of every matched trace, grouped by trace, newest trace first for
+     * {@code QueryOrder.BY_START_TIME} and longest first for {@code QueryOrder.BY_DURATION}.
+     */
+    List<List<SpanWrapper>> queryTraces(OTLPTraceQueryCondition condition) throws IOException;
+
+    default List<SpanWrapper> queryTraceByIdDebuggable(final String traceId,
+                                                       @Nullable final Duration duration) throws IOException {
+        final DebuggingTraceContext traceContext = DebuggingTraceContext.TRACE_CONTEXT.get();
+        DebuggingSpan span = null;
+        try {
+            if (traceContext != null) {
+                span = traceContext.createSpan("Query Dao: queryTraceById");
+                span.setMsg("Condition: TraceId: " + traceId + ", Duration: " + duration);
+            }
+            return queryTraceById(traceId, duration);
+        } finally {
+            if (traceContext != null && span != null) {
+                traceContext.stopSpan(span);
+            }
+        }
+    }
+
+    default List<List<SpanWrapper>> queryTracesDebuggable(final OTLPTraceQueryCondition condition) throws IOException {
+        final DebuggingTraceContext traceContext = DebuggingTraceContext.TRACE_CONTEXT.get();
+        DebuggingSpan span = null;
+        try {
+            if (traceContext != null) {
+                span = traceContext.createSpan("Query Dao: queryTraces");
+                span.setMsg("Condition: " + condition);
+            }
+            return queryTraces(condition);
+        } finally {
+            if (traceContext != null && span != null) {
+                traceContext.stopSpan(span);
+            }
+        }
+    }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/ttl/RecordsTTL.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/ttl/RecordsTTL.java
index 81e5b9a5ce..2873176d59 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/ttl/RecordsTTL.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/ttl/RecordsTTL.java
@@ -30,12 +30,14 @@ public class RecordsTTL {
     private final int normal;
     private final int trace;
     private final int zipkinTrace;
+    private final int otlpTrace;
     private final int log;
     private final int browserErrorLog;
 
     private int coldNormal = -1;
     private int coldTrace = -1;
     private int coldZipkinTrace = -1;
+    private int coldOtlpTrace = -1;
     private int coldLog = -1;
     private int coldBrowserErrorLog = -1;
 }
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/ttl/TTLDefinition.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/ttl/TTLDefinition.java
index 920d57dc89..40833c57b1 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/ttl/TTLDefinition.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/ttl/TTLDefinition.java
@@ -57,12 +57,14 @@ public class TTLDefinition {
         ttlDefinition.append("records.normal=").append(records.getNormal()).append("\n");
         ttlDefinition.append("records.trace=").append(records.getTrace()).append("\n");
         ttlDefinition.append("records.zipkinTrace=").append(records.getZipkinTrace()).append("\n");
+        ttlDefinition.append("records.otlpTrace=").append(records.getOtlpTrace()).append("\n");
         ttlDefinition.append("records.log=").append(records.getLog()).append("\n");
         ttlDefinition.append("records.browserErrorLog=").append(records.getBrowserErrorLog()).append("\n");
         ttlDefinition.append("# Cold data, '-1' represents no cold stage data.\n");
         ttlDefinition.append("records.normal.cold=").append(records.getColdNormal()).append("\n");
         ttlDefinition.append("records.trace.cold=").append(records.getColdTrace()).append("\n");
         ttlDefinition.append("records.zipkinTrace.cold=").append(records.getColdZipkinTrace()).append("\n");
+        ttlDefinition.append("records.otlpTrace.cold=").append(records.getColdOtlpTrace()).append("\n");
         ttlDefinition.append("records.log.cold=").append(records.getColdLog()).append("\n");
         ttlDefinition.append("records.browserErrorLog.cold=").append(records.getColdBrowserErrorLog()).append("\n");
         return ttlDefinition.toString();
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/OTLPSpanReader.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/OTLPSpanReader.java
index 21a0dc45af..2011f1fa26 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/OTLPSpanReader.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/OTLPSpanReader.java
@@ -17,6 +17,9 @@
 
 package org.apache.skywalking.oap.server.core.trace;
 
+import java.util.Collections;
+import java.util.Map;
+
 /**
  * Read-only abstraction over an OTLP span, decoupled from the protobuf
  * {@code io.opentelemetry.proto.trace.v1.Span} type so that {@code server-core}
@@ -66,4 +69,26 @@ public interface OTLPSpanReader {
      * @return the attribute value as a string, or empty string if not found
      */
     String getAttribute(String key);
+
+    /**
+     * @return the parent span id encoded as lowercase hex, empty for a root span
+     */
+    default String parentSpanId() {
+        return "";
+    }
+
+    /**
+     * @return the status code as the OTLP proto enum name, {@code STATUS_CODE_UNSET}, {@code STATUS_CODE_OK} or
+     * {@code STATUS_CODE_ERROR}
+     */
+    default String statusCode() {
+        return "STATUS_CODE_UNSET";
+    }
+
+    /**
+     * @return every span attribute, values rendered as strings; the first occurrence of a duplicated key wins
+     */
+    default Map<String, String> attributes() {
+        return Collections.emptyMap();
+    }
 }
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/SpanListener.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/SpanListener.java
index b8365a515c..b508ab8bdc 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/SpanListener.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/SpanListener.java
@@ -85,4 +85,24 @@ public interface SpanListener {
     default SpanListenerResult onZipkinSpan(final ZipkinSpan span) {
         return SpanListenerResult.CONTINUE;
     }
+
+    /**
+     * Phase 2 for OTLP spans stored natively, when {@code receiver-otel.otlpTraceStorage} is {@code otlp}. No
+     * Zipkin conversion happens in that mode, so {@link #onZipkinSpan} is never called for these spans; a listener
+     * that reads a span's attributes after phase 1 implements this method instead, over the same attribute
+     * semantics. It is called right after {@link #onOTLPSpan} for the same span, and only when phase 1 did not
+     * veto persistence.
+     *
+     * @param span               abstracted OTLP span (no proto dependency)
+     * @param resourceAttributes OTLP resource attributes as a flat map
+     * @param scopeName          InstrumentationScope name
+     * @param scopeVersion       InstrumentationScope version
+     * @return result controlling persistence and tag injection; injected tags are appended to the stored span
+     */
+    default SpanListenerResult onNativeOTLPSpan(final OTLPSpanReader span,
+                                                final Map<String, String> resourceAttributes,
+                                                final String scopeName,
+                                                final String scopeVersion) {
+        return SpanListenerResult.CONTINUE;
+    }
 }
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/SpanListenerManager.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/SpanListenerManager.java
index d4fa64a7b6..e87d83ec04 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/SpanListenerManager.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/trace/SpanListenerManager.java
@@ -23,6 +23,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.ServiceLoader;
+import java.util.function.Function;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.skywalking.oap.server.core.zipkin.source.ZipkinSpan;
 import org.apache.skywalking.oap.server.library.module.ModuleManager;
@@ -108,6 +109,26 @@ public class SpanListenerManager implements Service {
                                               final Map<String, String> resourceAttributes,
                                               final String scopeName,
                                               final String scopeVersion) {
+        return merge(listener -> listener.onOTLPSpan(span, resourceAttributes, scopeName, scopeVersion));
+    }
+
+    /**
+     * Phase 2 for natively stored OTLP spans, see {@link SpanListener#onNativeOTLPSpan}. Merged the way
+     * {@link #notifyOTLPPhase} merges: any veto drops the span, tags merge last-writer-wins.
+     */
+    public SpanListenerResult notifyNativeOTLPPhase(final OTLPSpanReader span,
+                                                    final Map<String, String> resourceAttributes,
+                                                    final String scopeName,
+                                                    final String scopeVersion) {
+        return merge(listener -> listener.onNativeOTLPSpan(span, resourceAttributes, scopeName, scopeVersion));
+    }
+
+    /**
+     * Runs one listener callback over every listener and merges the results: any veto drops the span, additional
+     * tags merge last-writer-wins. Each listener should use distinct tag keys; a collision is a design issue in the
+     * listener implementations.
+     */
+    private SpanListenerResult merge(final Function<SpanListener, SpanListenerResult> phase) {
         ensureInitialized();
         if (listeners.isEmpty()) {
             return SpanListenerResult.CONTINUE;
@@ -116,17 +137,13 @@ public class SpanListenerManager implements Service {
         Map<String, String> mergedTags = null;
 
         for (final SpanListener listener : listeners) {
-            final SpanListenerResult result = listener.onOTLPSpan(
-                span, resourceAttributes, scopeName, scopeVersion);
+            final SpanListenerResult result = phase.apply(listener);
             if (result == SpanListenerResult.CONTINUE) {
                 continue;
             }
             if (!result.isShouldPersist()) {
                 shouldPersist = false;
             }
-            // Merge additional tags — last-writer-wins if multiple listeners
-            // set the same key. Each listener should use distinct tag keys;
-            // collisions indicate a design issue in the listener implementations.
             if (!result.getAdditionalTags().isEmpty()) {
                 if (mergedTags == null) {
                     mergedTags = new HashMap<>();
diff --git a/oap-server/server-core/src/main/proto/SpanWrapper.proto b/oap-server/server-core/src/main/proto/SpanWrapper.proto
index 823e3bebd5..24b79a5f21 100644
--- a/oap-server/server-core/src/main/proto/SpanWrapper.proto
+++ b/oap-server/server-core/src/main/proto/SpanWrapper.proto
@@ -33,4 +33,6 @@ enum Source {
     SKYWALKING_EVENT = 1;
     ZIPKIN = 2;
     ZIPKIN_EVENT = 3;
+    // A single-span OTLP ResourceSpans message, stored verbatim by OTLPSpanRecord.
+    OTLP = 4;
 }
\ No newline at end of file
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/antlr4/org/apache/skywalking/oap/query/tempo/grammar/TraceQLLexer.g4 b/oap-server/server-query-plugin/traceql-plugin/src/main/antlr4/org/apache/skywalking/oap/query/tempo/grammar/TraceQLLexer.g4
index 4b2f2eb107..3a14908290 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/antlr4/org/apache/skywalking/oap/query/tempo/grammar/TraceQLLexer.g4
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/antlr4/org/apache/skywalking/oap/query/tempo/grammar/TraceQLLexer.g4
@@ -36,6 +36,7 @@ SPAN:        'span';
 
 // Operators
 DOT:         '.';
+COLON:       ':';
 COMMA:       ',';
 L_PAREN:     '(';
 R_PAREN:     ')';
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/antlr4/org/apache/skywalking/oap/query/tempo/grammar/TraceQLParser.g4 b/oap-server/server-query-plugin/traceql-plugin/src/main/antlr4/org/apache/skywalking/oap/query/tempo/grammar/TraceQLParser.g4
index cf41d8f448..387bb8a69f 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/antlr4/org/apache/skywalking/oap/query/tempo/grammar/TraceQLParser.g4
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/antlr4/org/apache/skywalking/oap/query/tempo/grammar/TraceQLParser.g4
@@ -70,9 +70,10 @@ scope
     | LINK
     ;
 
-// Intrinsic fields (using IDENTIFIER to avoid keyword conflicts)
+// Intrinsic fields (using IDENTIFIER to avoid keyword conflicts), plain or with Tempo's scope prefix:
+// duration, name, status, kind, span:kind, span:duration, trace:duration, event:name
 intrinsicField
-    : IDENTIFIER  // duration, name, status, kind, parent, traceID, rootName, rootServiceName
+    : ((scope | IDENTIFIER) COLON)? IDENTIFIER
     ;
 
 // Operators
@@ -95,5 +96,6 @@ staticValue
     | TRUE                                                              # TrueLiteral
     | FALSE                                                             # FalseLiteral
     | NIL                                                               # NilLiteral
+    | IDENTIFIER                                                        # KeywordLiteral  // TraceQL enums: kind = server, status = error
     ;
 
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/TraceQLConfig.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/TraceQLConfig.java
index 295cbe9125..d3973fe361 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/TraceQLConfig.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/TraceQLConfig.java
@@ -30,8 +30,13 @@ public class TraceQLConfig extends ModuleConfig {
     private int restPort;
     private boolean enableDatasourceZipkin;
     private boolean enableDatasourceSkywalking;
+    /**
+     * Serves natively stored OTLP spans, {@code receiver-otel.otlpTraceStorage: otlp}, with no format mapping.
+     */
+    private boolean enableDatasourceOTLP;
     private String restContextPathZipkin;
     private String restContextPathSkywalking;
+    private String restContextPathOTLP;
     private long restIdleTimeOut = 30000;
     private int restAcceptQueueSize = 0;
     /**
@@ -44,12 +49,19 @@ public class TraceQLConfig extends ModuleConfig {
     private long lookback = 86400000L;
     private String zipkinTracesListResultTags = ZIPKIN_TRACES_LIST_RESULT_TAGS;
     private String skywalkingTracesListResultTags = SKYWALKING_TRACES_LIST_RESULT_TAGS;
+    private String otlpTracesListResultTags = OTLP_TRACES_LIST_RESULT_TAGS;
 
     private static final String ZIPKIN_TRACES_LIST_RESULT_TAGS = String.join(
         Const.COMMA,
         "http.method",
         "error"
     );
+
+    private static final String OTLP_TRACES_LIST_RESULT_TAGS = String.join(
+        Const.COMMA,
+        "http.request.method",
+        "http.response.status_code"
+    );
     private static final String SKYWALKING_TRACES_LIST_RESULT_TAGS = String.join(
         Const.COMMA,
         "http.method",
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/TraceQLProvider.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/TraceQLProvider.java
index 9f9a0eabf4..24ad3b9def 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/TraceQLProvider.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/TraceQLProvider.java
@@ -20,9 +20,11 @@ package org.apache.skywalking.oap.query.traceql;
 
 import com.linecorp.armeria.common.HttpMethod;
 import java.util.Collections;
+import org.apache.skywalking.oap.query.traceql.handler.OTLPTraceQLApiHandler;
 import org.apache.skywalking.oap.query.traceql.handler.SkyWalkingTraceQLApiHandler;
 import org.apache.skywalking.oap.query.traceql.handler.ZipkinTraceQLApiHandler;
 import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.storage.StorageModule;
 import org.apache.skywalking.oap.server.core.RunningMode;
 import org.apache.skywalking.oap.server.library.module.ModuleDefine;
 import org.apache.skywalking.oap.server.library.module.ModuleProvider;
@@ -99,6 +101,14 @@ public class TraceQLProvider extends ModuleProvider {
                 config.getRestContextPathSkywalking()
             );
         }
+        if (config.isEnableDatasourceOTLP()) {
+            // Natively stored OTLP spans, served as the OTLP they arrived as, with /otlp context path
+            httpServer.addHandler(
+                new OTLPTraceQLApiHandler(getManager(), config),
+                Collections.singletonList(HttpMethod.GET),
+                config.getRestContextPathOTLP()
+            );
+        }
     }
 
     @Override
@@ -115,7 +125,9 @@ public class TraceQLProvider extends ModuleProvider {
     @Override
     public String[] requiredModules() {
         return new String[] {
-            CoreModule.NAME
+            CoreModule.NAME,
+            // The /otlp handler constructs OTLPTraceQueryService, which finds the storage DAO through the module manager.
+            StorageModule.NAME
         };
     }
 }
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/OTLPSpanMatcher.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/OTLPSpanMatcher.java
new file mode 100644
index 0000000000..366ae3ac31
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/OTLPSpanMatcher.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import io.opentelemetry.proto.common.v1.InstrumentationScope;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.Span;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.otlp.OTLPSpanRecord;
+import org.apache.skywalking.oap.server.core.query.input.OTLPTraceQueryCondition;
+import org.apache.skywalking.oap.server.receiver.otel.otlp.OTLPValues;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+/**
+ * Re-evaluates an {@link OTLPTraceQueryCondition} against one stored span, so a search result lists the spans that
+ * matched instead of every span of the matched trace, as Tempo does. The storage applied the same conditions per
+ * span already; this mirrors them over the decoded {@code ResourceSpans}, resolving service and instance from the
+ * resource the way the receiver did, formatting the service, span and peer names with the same {@link NamingControl}
+ * the receiver indexed them with, and rendering attributes the way the {@code key=value} index did.
+ */
+public final class OTLPSpanMatcher {
+    private final OTLPTraceQueryCondition condition;
+    private final NamingControl namingControl;
+    /**
+     * The index forms of every tag condition, see {@link OTLPSpanRecord#indexedTags}: one entry per condition,
+     * holding the one scoped form or the two forms an unscoped key accepts.
+     */
+    private final List<List<String>> tags = new ArrayList<>();
+
+    /**
+     * Compares the raw names; for tests. Production code passes the {@link NamingControl}.
+     */
+    public OTLPSpanMatcher(final OTLPTraceQueryCondition condition) {
+        this(condition, null);
+    }
+
+    /**
+     * @param namingControl the rules {@code OTLPSpanForward} applied to the indexed service, span and peer names, so
+     *                      the comparison sees the names the storage matched; null compares the raw names
+     */
+    public OTLPSpanMatcher(final OTLPTraceQueryCondition condition, final NamingControl namingControl) {
+        this.condition = condition;
+        this.namingControl = namingControl;
+        if (condition.getTags() != null) {
+            for (final Tag tag : condition.getTags()) {
+                tags.add(OTLPSpanRecord.indexedTags(tag.getKey(), tag.getValue()));
+            }
+        }
+    }
+
+    public boolean matches(final Resource resource, final InstrumentationScope scope, final Span span) {
+        final String serviceName = formatServiceName(
+            attribute(resource.getAttributesList(), OTLPSpanRecord.SERVICE_NAME_RESOURCE_KEYS));
+        if (differs(condition.getServiceName(), serviceName)
+            || differs(condition.getServiceInstance(), attribute(resource.getAttributesList(), OTLPSpanRecord.SERVICE_INSTANCE_RESOURCE_KEYS))
+            || differs(condition.getScopeName(), scope.getName())
+            || differs(condition.getSpanName(), formatSpanName(serviceName, span.getName()))
+            || differs(condition.getPeerService(), formatServiceName(
+                attribute(span.getAttributesList(), List.of(OTLPSpanRecord.PEER_SERVICE_ATTRIBUTE))))) {
+            return false;
+        }
+        if (condition.getKind() != null && condition.getKind() != span.getKindValue()) {
+            return false;
+        }
+        if (condition.getStatusCode() != null && condition.getStatusCode() != span.getStatus().getCodeValue()) {
+            return false;
+        }
+        final long duration = span.getEndTimeUnixNano() - span.getStartTimeUnixNano();
+        if (condition.getMinDurationNanos() != null && duration < condition.getMinDurationNanos()) {
+            return false;
+        }
+        if (condition.getMaxDurationNanos() != null && duration > condition.getMaxDurationNanos()) {
+            return false;
+        }
+        if (tags.isEmpty()) {
+            return true;
+        }
+        final Set<String> rendered = new HashSet<>();
+        for (final KeyValue attribute : resource.getAttributesList()) {
+            rendered.add(OTLPSpanRecord.RESOURCE_TAG_PREFIX + attribute.getKey() + "=" + OTLPValues.render(attribute.getValue()));
+        }
+        for (final KeyValue attribute : span.getAttributesList()) {
+            rendered.add(OTLPSpanRecord.SPAN_TAG_PREFIX + attribute.getKey() + "=" + OTLPValues.render(attribute.getValue()));
+        }
+        for (final List<String> forms : tags) {
+            if (forms.stream().noneMatch(rendered::contains)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean differs(final String expected, final String actual) {
+        return StringUtil.isNotEmpty(expected) && !expected.equals(actual);
+    }
+
+    private String formatServiceName(final String name) {
+        return namingControl == null || StringUtil.isEmpty(name) ? name : namingControl.formatServiceName(name);
+    }
+
+    private String formatSpanName(final String serviceName, final String name) {
+        return namingControl == null ? name : namingControl.formatEndpointName(serviceName, name);
+    }
+
+    /**
+     * The rendered value of the first key present with a non-empty value, or an empty string.
+     */
+    static String attribute(final List<KeyValue> attributes, final List<String> keys) {
+        for (final String key : keys) {
+            for (final KeyValue attribute : attributes) {
+                if (key.equals(attribute.getKey())) {
+                    final String value = OTLPValues.render(attribute.getValue());
+                    if (StringUtil.isNotEmpty(value)) {
+                        return value;
+                    }
+                }
+            }
+        }
+        return "";
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/OTLPTraceAssembler.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/OTLPTraceAssembler.java
new file mode 100644
index 0000000000..7a101d28c6
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/OTLPTraceAssembler.java
@@ -0,0 +1,454 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.util.JsonFormat;
+import io.grafana.tempo.tempopb.Trace;
+import io.grafana.tempo.tempopb.TraceByIDResponse;
+import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.InstrumentationScope;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
+import io.opentelemetry.proto.trace.v1.ScopeSpans;
+import io.opentelemetry.proto.trace.v1.Span;
+import io.opentelemetry.proto.trace.v1.Status;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.skywalking.oap.query.traceql.entity.SearchResponse;
+import org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler;
+import org.apache.skywalking.oap.server.core.otlp.OTLPSpanRecord;
+import org.apache.skywalking.oap.server.receiver.otel.otlp.OTLPValues;
+import org.apache.skywalking.oap.server.core.storage.query.proto.Source;
+import org.apache.skywalking.oap.server.core.storage.query.proto.SpanWrapper;
+
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SCOPE_RESOURCE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SERVICE_NAME;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SPAN_KIND;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.STATUS;
+
+/**
+ * Turns the single-span {@code ResourceSpans} messages the native OTLP store keeps back into the wire shape an
+ * exporter would have produced for a whole trace, and renders them as Tempo responses. Nothing is converted
+ * between formats here: the stored bytes are the OTLP the SDK sent, this class only regroups them.
+ */
+public final class OTLPTraceAssembler {
+    private static final JsonFormat.Printer PRINTER = JsonFormat.printer().omittingInsignificantWhitespace();
+    private static final String TRACE_ID = "traceId";
+    private static final String SPAN_ID = "spanId";
+    private static final String PARENT_SPAN_ID = "parentSpanId";
+
+    /**
+     * The tag autocomplete key and TraceQL attribute the instrumentation scope name is published under.
+     */
+    public static final String OTEL_SCOPE_NAME = "otel.scope.name";
+
+    private OTLPTraceAssembler() {
+    }
+
+    /**
+     * The attribute keys of one scope on the spans of the sampled traces that the matcher accepts, for Tempo's
+     * filtered {@code /api/v2/search/tags}. Every attribute is indexed, so every key listed is filterable.
+     */
+    public static List<String> tagNames(final List<List<ResourceSpans>> traces,
+                                        final OTLPSpanMatcher matcher,
+                                        final String scope) {
+        final Set<String> keys = new LinkedHashSet<>();
+        for (final StoredSpan stored : matched(traces, matcher)) {
+            if (SCOPE_RESOURCE.equals(scope)) {
+                for (final KeyValue attribute : stored.resource.getAttributesList()) {
+                    keys.add(attribute.getKey());
+                }
+            } else {
+                if (!stored.scope.getName().isEmpty()) {
+                    keys.add(OTEL_SCOPE_NAME);
+                }
+                for (final KeyValue attribute : stored.span.getAttributesList()) {
+                    keys.add(attribute.getKey());
+                }
+            }
+        }
+        return new ArrayList<>(keys);
+    }
+
+    /**
+     * The distinct values of one tag on the spans of the sampled traces that the matcher accepts, for Tempo's
+     * filtered {@code /api/v2/search/tag/{tag}/values}. {@code tag} is the normalized name the handler switches on.
+     */
+    public static List<String> tagValues(final List<List<ResourceSpans>> traces,
+                                         final OTLPSpanMatcher matcher,
+                                         final String tag) {
+        final List<StoredSpan> spans = matched(traces, matcher);
+        final Set<String> values = new LinkedHashSet<>();
+        for (final StoredSpan stored : spans) {
+            final String value = tagValue(stored, tag);
+            if (value != null && !value.isEmpty()) {
+                values.add(value);
+            }
+        }
+        return new ArrayList<>(values);
+    }
+
+    /**
+     * TraceQL's spelling of the span status: {@code error}, {@code ok} or {@code unset}.
+     */
+    static String statusSpelling(final Span span) {
+        return span.getStatus().getCode().name().substring("STATUS_CODE_".length()).toLowerCase(Locale.ROOT);
+    }
+
+    private static String tagValue(final StoredSpan stored, final String tag) {
+        switch (tag) {
+            case TraceQLApiHandler.RESOURCE_SERVICE_NAME:
+            case TraceQLApiHandler.RESOURCE_SERVICE:
+            case SERVICE_NAME:
+                return stored.serviceName();
+            case TraceQLApiHandler.RESOURCE_INSTANCE:
+            case SCOPE_RESOURCE + "." + TraceQLApiHandler.SERVICE_INSTANCE_ID:
+                return OTLPSpanMatcher.attribute(stored.resource.getAttributesList(), OTLPSpanRecord.SERVICE_INSTANCE_RESOURCE_KEYS);
+            case TraceQLApiHandler.RESOURCE_REMOTE_SERVICE:
+                return OTLPSpanMatcher.attribute(stored.span.getAttributesList(), List.of(OTLPSpanRecord.PEER_SERVICE_ATTRIBUTE));
+            case TraceQLApiHandler.NAME:
+                return stored.span.getName();
+            case TraceQLApiHandler.STATUS:
+                return statusSpelling(stored.span);
+            case TraceQLApiHandler.KIND:
+                return stored.span.getKind().name().substring("SPAN_KIND_".length()).toLowerCase(Locale.ROOT);
+            case TraceQLApiHandler.DURATION:
+                return null;
+            case OTEL_SCOPE_NAME:
+            case TraceQLApiHandler.SPAN_PREFIX + OTEL_SCOPE_NAME:
+                return stored.scope.getName();
+            default:
+                if (tag.startsWith(TraceQLApiHandler.SPAN_PREFIX)) {
+                    return OTLPSpanMatcher.attribute(stored.span.getAttributesList(), List.of(tag.substring(TraceQLApiHandler.SPAN_PREFIX.length())));
+                }
+                if (tag.startsWith(SCOPE_RESOURCE + ".")) {
+                    return OTLPSpanMatcher.attribute(stored.resource.getAttributesList(), List.of(tag.substring(SCOPE_RESOURCE.length() + 1)));
+                }
+                return null;
+        }
+    }
+
+    private static List<StoredSpan> matched(final List<List<ResourceSpans>> traces, final OTLPSpanMatcher matcher) {
+        final List<StoredSpan> spans = new ArrayList<>();
+        for (final List<ResourceSpans> trace : traces) {
+            for (final StoredSpan stored : flatten(trace)) {
+                if (matcher == null || matcher.matches(stored.resource, stored.scope, stored.span)) {
+                    spans.add(stored);
+                }
+            }
+        }
+        return spans;
+    }
+
+    /**
+     * Decode stored wrappers. A wrapper of another {@link Source} is skipped: the store holds OTLP only, so one
+     * would be a bug elsewhere, not a reason to fail a read.
+     */
+    public static List<ResourceSpans> decode(final List<SpanWrapper> wrappers) throws InvalidProtocolBufferException {
+        final List<ResourceSpans> spans = new ArrayList<>(wrappers.size());
+        for (final SpanWrapper wrapper : wrappers) {
+            if (wrapper.getSource() != Source.OTLP) {
+                continue;
+            }
+            spans.add(ResourceSpans.parseFrom(wrapper.getSpan()));
+        }
+        return spans;
+    }
+
+    /**
+     * Group single-span messages by resource identity, byte-equal {@code Resource} plus schema URL, then by scope
+     * identity, and order the spans of each scope by start time. That is the wire shape of one export of the
+     * whole trace and the only regrouping this datasource performs.
+     */
+    public static TraceByIDResponse assemble(final List<ResourceSpans> singleSpanMessages) {
+        final Map<ResourceKey, Map<ScopeKey, List<Span>>> grouped = new LinkedHashMap<>();
+        for (final ResourceSpans message : singleSpanMessages) {
+            final ResourceKey resourceKey = new ResourceKey(message.getResource(), message.getSchemaUrl());
+            final Map<ScopeKey, List<Span>> scopes = grouped.computeIfAbsent(resourceKey, k -> new LinkedHashMap<>());
+            for (final ScopeSpans scopeSpans : message.getScopeSpansList()) {
+                final ScopeKey scopeKey = new ScopeKey(scopeSpans.getScope(), scopeSpans.getSchemaUrl());
+                scopes.computeIfAbsent(scopeKey, k -> new ArrayList<>()).addAll(scopeSpans.getSpansList());
+            }
+        }
+        final Trace.Builder trace = Trace.newBuilder();
+        grouped.forEach((resourceKey, scopes) -> {
+            final ResourceSpans.Builder resourceSpans = ResourceSpans.newBuilder()
+                                                                     .setResource(resourceKey.resource)
+                                                                     .setSchemaUrl(resourceKey.schemaUrl);
+            scopes.forEach((scopeKey, spans) -> {
+                spans.sort(Comparator.comparingLong(Span::getStartTimeUnixNano));
+                resourceSpans.addScopeSpans(ScopeSpans.newBuilder()
+                                                      .setScope(scopeKey.scope)
+                                                      .setSchemaUrl(scopeKey.schemaUrl)
+                                                      .addAllSpans(spans));
+            });
+            trace.addResourceSpans(resourceSpans);
+        });
+        return TraceByIDResponse.newBuilder().setTrace(trace).build();
+    }
+
+    /**
+     * The OTLP/JSON encoding of the response: typed {@code AnyValue}s, enums by name, 64-bit integers as strings,
+     * as protobuf's JSON mapping defines them, with one override: ids are printed as lowercase hex, not base64,
+     * because that is what Tempo's API and Grafana expect.
+     */
+    public static String toJson(final TraceByIDResponse response) throws IOException {
+        final JsonObject root = JsonParser.parseString(PRINTER.print(response)).getAsJsonObject();
+        final JsonObject trace = root.getAsJsonObject("trace");
+        if (trace == null) {
+            return root.toString();
+        }
+        for (final JsonElement resourceSpans : arrayOf(trace, "resourceSpans")) {
+            for (final JsonElement scopeSpans : arrayOf(resourceSpans.getAsJsonObject(), "scopeSpans")) {
+                for (final JsonElement span : arrayOf(scopeSpans.getAsJsonObject(), "spans")) {
+                    final JsonObject spanObject = span.getAsJsonObject();
+                    base64ToHex(spanObject, TRACE_ID);
+                    base64ToHex(spanObject, SPAN_ID);
+                    base64ToHex(spanObject, PARENT_SPAN_ID);
+                    for (final JsonElement link : arrayOf(spanObject, "links")) {
+                        base64ToHex(link.getAsJsonObject(), TRACE_ID);
+                        base64ToHex(link.getAsJsonObject(), SPAN_ID);
+                    }
+                }
+            }
+        }
+        return root.toString();
+    }
+
+    /**
+     * The Tempo search result for traces, each given as the single-span messages the store returned for it.
+     *
+     * @param allowedTags     the attribute keys listed per span, besides the fixed {@code service.name} and
+     *                        {@code span.kind}
+     * @param matcher         selects the spans the span set lists, null lists every span of the trace
+     * @param spansPerSpanSet Tempo's {@code spss}, the most spans a span set lists; zero or less lists all matches
+     */
+    public static SearchResponse toSearchResponse(final List<List<ResourceSpans>> traces,
+                                                  final Set<String> allowedTags,
+                                                  final OTLPSpanMatcher matcher,
+                                                  final int spansPerSpanSet) {
+        final SearchResponse response = new SearchResponse();
+        for (final List<ResourceSpans> trace : traces) {
+            final List<StoredSpan> spans = flatten(trace);
+            if (spans.isEmpty()) {
+                continue;
+            }
+            final SearchResponse.Trace searchTrace = toSearchTrace(spans, allowedTags, matcher, spansPerSpanSet);
+            if (searchTrace != null) {
+                response.getTraces().add(searchTrace);
+            }
+        }
+        return response;
+    }
+
+    /**
+     * @return null when the matcher accepts none of the spans. The storage matched the trace on the same conditions,
+     * so the two evaluations disagree; the trace is left out rather than listed with spans the query excluded, the
+     * failure #14093 describes.
+     */
+    private static SearchResponse.Trace toSearchTrace(final List<StoredSpan> spans,
+                                                      final Set<String> allowedTags,
+                                                      final OTLPSpanMatcher matcher,
+                                                      final int spansPerSpanSet) {
+        final SearchResponse.Trace trace = new SearchResponse.Trace();
+        final StoredSpan first = spans.get(0);
+        trace.setTraceID(hex(first.span.getTraceId().toByteArray()));
+        final StoredSpan root = spans.stream()
+                                     .filter(s -> s.span.getParentSpanId().isEmpty())
+                                     .findFirst()
+                                     .orElse(first);
+        trace.setRootServiceName(root.serviceName());
+        trace.setRootTraceName(root.span.getName());
+
+        final long minStart = spans.stream().mapToLong(s -> s.span.getStartTimeUnixNano()).min().orElse(0L);
+        final long maxEnd = spans.stream().mapToLong(s -> s.span.getEndTimeUnixNano()).max().orElse(minStart);
+        trace.setStartTimeUnixNano(String.valueOf(minStart));
+        trace.setDurationMs((int) ((maxEnd - minStart) / 1_000_000L));
+
+        for (final StoredSpan stored : spans) {
+            final SearchResponse.ServiceStat stat = trace.getServiceStats()
+                                                         .computeIfAbsent(stored.serviceName(), k -> new SearchResponse.ServiceStat(0, 0));
+            stat.setSpanCount(stat.getSpanCount() + 1);
+            if (stored.span.getStatus().getCode() == Status.StatusCode.STATUS_CODE_ERROR) {
+                stat.setErrorCount(stat.getErrorCount() + 1);
+            }
+        }
+
+        List<StoredSpan> matched = spans;
+        if (matcher != null) {
+            matched = spans.stream()
+                           .filter(s -> matcher.matches(s.resource, s.scope, s.span))
+                           .collect(Collectors.toList());
+            if (matched.isEmpty()) {
+                return null;
+            }
+        }
+        final List<StoredSpan> listed = spansPerSpanSet > 0 && matched.size() > spansPerSpanSet
+            ? matched.subList(0, spansPerSpanSet) : matched;
+
+        // Every span lists the same keys, missing ones padded with an empty string: Grafana's search.go:369
+        // appends "" to a []*string field when a key is absent and panics on nil.
+        final Set<String> keys = new LinkedHashSet<>();
+        for (final StoredSpan stored : listed) {
+            keys.addAll(stored.attributes().keySet());
+        }
+        keys.retainAll(allowedTags);
+        keys.add(SERVICE_NAME);
+        keys.add(SPAN_KIND);
+        keys.add(STATUS);
+
+        final SearchResponse.SpanSet spanSet = new SearchResponse.SpanSet();
+        for (final StoredSpan stored : listed) {
+            final SearchResponse.Span span = new SearchResponse.Span();
+            span.setSpanID(hex(stored.span.getSpanId().toByteArray()));
+            span.setStartTimeUnixNano(String.valueOf(stored.span.getStartTimeUnixNano()));
+            span.setDurationNanos(String.valueOf(stored.span.getEndTimeUnixNano() - stored.span.getStartTimeUnixNano()));
+            final Map<String, AnyValue> attributes = stored.attributes();
+            for (final String key : keys) {
+                final SearchResponse.Attribute attribute = new SearchResponse.Attribute();
+                attribute.setKey(key);
+                final SearchResponse.Value value = new SearchResponse.Value();
+                if (SPAN_KIND.equals(key)) {
+                    value.setStringValue(stored.span.getKind().name());
+                } else if (STATUS.equals(key)) {
+                    // The span status as a fixed attribute, so a trace list can show failures without opening each
+                    // trace: Tempo's search span has no status field of its own (#14093).
+                    value.setStringValue(statusSpelling(stored.span));
+                } else if (SERVICE_NAME.equals(key) && !attributes.containsKey(SERVICE_NAME)) {
+                    value.setStringValue(stored.serviceName());
+                } else if (attributes.containsKey(key)) {
+                    fill(value, attributes.get(key));
+                } else {
+                    value.setStringValue("");
+                }
+                attribute.setValue(value);
+                span.getAttributes().add(attribute);
+            }
+            spanSet.getSpans().add(span);
+        }
+        spanSet.setMatched(matched.size());
+        trace.getSpanSets().add(spanSet);
+        return trace;
+    }
+
+    private static void fill(final SearchResponse.Value value, final AnyValue anyValue) {
+        if (anyValue.hasIntValue()) {
+            value.setIntValue(String.valueOf(anyValue.getIntValue()));
+        } else if (anyValue.hasBoolValue()) {
+            value.setBoolValue(anyValue.getBoolValue());
+        } else if (anyValue.hasDoubleValue()) {
+            value.setDoubleValue(anyValue.getDoubleValue());
+        } else {
+            value.setStringValue(OTLPValues.render(anyValue));
+        }
+    }
+
+    private static List<StoredSpan> flatten(final List<ResourceSpans> trace) {
+        final List<StoredSpan> spans = new ArrayList<>();
+        for (final ResourceSpans resourceSpans : trace) {
+            for (final ScopeSpans scopeSpans : resourceSpans.getScopeSpansList()) {
+                for (final Span span : scopeSpans.getSpansList()) {
+                    spans.add(new StoredSpan(resourceSpans.getResource(), scopeSpans.getScope(), span));
+                }
+            }
+        }
+        return spans;
+    }
+
+    private static JsonArray arrayOf(final JsonObject object, final String member) {
+        final JsonElement element = object.get(member);
+        return element != null && element.isJsonArray() ? element.getAsJsonArray() : new JsonArray();
+    }
+
+    private static void base64ToHex(final JsonObject object, final String member) {
+        final JsonElement element = object.get(member);
+        if (element == null || !element.isJsonPrimitive()) {
+            return;
+        }
+        object.addProperty(member, hex(Base64.getDecoder().decode(element.getAsString())));
+    }
+
+    private static String hex(final byte[] bytes) {
+        return Hex.encodeHexString(bytes);
+    }
+
+    @AllArgsConstructor
+    @EqualsAndHashCode
+    private static final class ResourceKey {
+        private final Resource resource;
+        private final String schemaUrl;
+    }
+
+    @AllArgsConstructor
+    @EqualsAndHashCode
+    private static final class ScopeKey {
+        private final InstrumentationScope scope;
+        private final String schemaUrl;
+    }
+
+    /**
+     * A span with the resource and instrumentation scope it was stored under.
+     */
+    @AllArgsConstructor
+    private static final class StoredSpan {
+        private final Resource resource;
+        private final InstrumentationScope scope;
+        private final Span span;
+
+        /**
+         * The service name the receiver stored, resolved from the same resource keys in the same order.
+         */
+        String serviceName() {
+            return OTLPSpanMatcher.attribute(resource.getAttributesList(), OTLPSpanRecord.SERVICE_NAME_RESOURCE_KEYS);
+        }
+
+        /**
+         * Resource and span attributes as one map, the span winning a duplicated key, the view the search list
+         * filters and pads.
+         */
+        Map<String, AnyValue> attributes() {
+            final Map<String, AnyValue> attributes = new LinkedHashMap<>();
+            for (final KeyValue attribute : resource.getAttributesList()) {
+                attributes.put(attribute.getKey(), attribute.getValue());
+            }
+            for (final KeyValue attribute : span.getAttributesList()) {
+                attributes.put(attribute.getKey(), attribute.getValue());
+            }
+            return attributes;
+        }
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingOTLPConverter.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingOTLPConverter.java
index 791e372941..451d58ce82 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingOTLPConverter.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingOTLPConverter.java
@@ -36,17 +36,31 @@ import org.apache.skywalking.oap.server.core.Const;
 import org.apache.skywalking.oap.server.core.query.type.LogEntity;
 import org.apache.skywalking.oap.server.core.query.type.Ref;
 import org.apache.skywalking.oap.server.core.query.type.trace.v2.TraceList;
+import org.apache.skywalking.oap.server.core.query.type.trace.v2.TraceV2;
 import org.apache.skywalking.oap.server.library.util.StringUtil;
 
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-
+import java.util.stream.Collectors;
+
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.ERROR;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.INSTANCE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.NAME;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.OK;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.RESOURCE_INSTANCE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.RESOURCE_SERVICE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.RESOURCE_SERVICE_NAME;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SCOPE_RESOURCE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SERVICE;
 import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SERVICE_NAME;
 import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SPAN_KIND;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SPAN_PREFIX;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.STATUS;
 
 /**
  * Converter for transforming SkyWalking trace data to OpenTelemetry Protocol (OTLP) format.
@@ -272,23 +286,107 @@ public class SkyWalkingOTLPConverter {
                                 .build();
     }
 
+    /**
+     * The tag keys of one scope on the spans of the sampled traces that the matcher accepts, for Tempo's filtered
+     * {@code /api/v2/search/tags}. Only the keys in {@code searchableKeys} are query conditions on this store, so
+     * the span scope is cut down to them; SkyWalking spans have no resource, so that scope keeps its fixed names.
+     */
+    public static List<String> tagNames(TraceList traceList, SkyWalkingSpanMatcher matcher, String scope,
+                                        Set<String> searchableKeys) {
+        if (SCOPE_RESOURCE.equals(scope)) {
+            return Arrays.asList(SERVICE, INSTANCE);
+        }
+        Set<String> keys = new LinkedHashSet<>();
+        for (org.apache.skywalking.oap.server.core.query.type.Span span : matched(traceList, matcher)) {
+            for (org.apache.skywalking.oap.server.core.query.type.KeyValue tag : span.getTags()) {
+                if (searchableKeys.contains(tag.getKey())) {
+                    keys.add(tag.getKey());
+                }
+            }
+        }
+        return new ArrayList<>(keys);
+    }
+
+    /**
+     * The distinct values of one tag on the spans of the sampled traces that the matcher accepts, for Tempo's
+     * filtered {@code /api/v2/search/tag/{tag}/values}. {@code tag} is the normalized name the handler switches on.
+     */
+    public static List<String> tagValues(TraceList traceList, SkyWalkingSpanMatcher matcher, String tag) {
+        Set<String> values = new LinkedHashSet<>();
+        for (org.apache.skywalking.oap.server.core.query.type.Span span : matched(traceList, matcher)) {
+            String value = tagValue(span, tag);
+            if (StringUtil.isNotEmpty(value)) {
+                values.add(value);
+            }
+        }
+        return new ArrayList<>(values);
+    }
+
+    private static String tagValue(org.apache.skywalking.oap.server.core.query.type.Span span, String tag) {
+        switch (tag) {
+            case RESOURCE_SERVICE_NAME:
+            case RESOURCE_SERVICE:
+                return span.getServiceCode();
+            case RESOURCE_INSTANCE:
+                return span.getServiceInstanceName();
+            case NAME:
+                return span.getEndpointName();
+            case STATUS:
+                return span.isError() ? ERROR : OK;
+            default:
+                if (tag.startsWith(SPAN_PREFIX)) {
+                    String key = tag.substring(SPAN_PREFIX.length());
+                    for (org.apache.skywalking.oap.server.core.query.type.KeyValue kv : span.getTags()) {
+                        if (key.equals(kv.getKey())) {
+                            return kv.getValue();
+                        }
+                    }
+                }
+                return null;
+        }
+    }
+
+    private static List<org.apache.skywalking.oap.server.core.query.type.Span> matched(TraceList traceList,
+                                                                                        SkyWalkingSpanMatcher matcher) {
+        List<org.apache.skywalking.oap.server.core.query.type.Span> spans = new ArrayList<>();
+        if (traceList == null || traceList.getTraces() == null) {
+            return spans;
+        }
+        for (TraceV2 trace : traceList.getTraces()) {
+            for (org.apache.skywalking.oap.server.core.query.type.Span span : trace.getSpans()) {
+                if (matcher == null || matcher.matches(span)) {
+                    spans.add(span);
+                }
+            }
+        }
+        return spans;
+    }
+
     /**
      * Convert TraceList to SearchResponse format.
      *
-     * @param traceList   SkyWalking trace list
-     * @param allowedTags Only span attributes whose key is in this set are included; null means all
+     * @param traceList       SkyWalking trace list
+     * @param allowedTags     Only span attributes whose key is in this set are included; null means all
+     * @param matcher         selects the spans the span set lists, null lists every span of the trace
+     * @param spansPerSpanSet Tempo's {@code spss}, the most spans a span set lists; zero or less lists all matches
      * @return SearchResponse containing the converted traces
      */
-    public static SearchResponse convertTraceListToSearchResponse(TraceList traceList, Set<String> allowedTags) {
+    public static SearchResponse convertTraceListToSearchResponse(TraceList traceList,
+                                                                  Set<String> allowedTags,
+                                                                  SkyWalkingSpanMatcher matcher,
+                                                                  int spansPerSpanSet) {
         SearchResponse response = new SearchResponse();
         List<SearchResponse.Trace> traces = new ArrayList<>();
 
         if (traceList != null && traceList.getTraces() != null) {
-            for (org.apache.skywalking.oap.server.core.query.type.trace.v2.TraceV2 trace : traceList.getTraces()) {
+            for (TraceV2 trace : traceList.getTraces()) {
                 if (trace.getSpans().isEmpty()) {
                     continue;
                 }
-                traces.add(convertSWTraceToSearchTrace(trace, allowedTags));
+                final SearchResponse.Trace searchTrace = convertSWTraceToSearchTrace(trace, allowedTags, matcher, spansPerSpanSet);
+                if (searchTrace != null) {
+                    traces.add(searchTrace);
+                }
             }
         }
 
@@ -299,12 +397,15 @@ public class SkyWalkingOTLPConverter {
     /**
      * Convert a single SkyWalking TraceV2 to SearchResponse.Trace.
      *
-     * @param swTrace       SkyWalking TraceV2
-     * @param allowedTags Only span attributes whose key is in this set are included; null means all
+     * @param swTrace         SkyWalking TraceV2
+     * @param allowedTags     Only span attributes whose key is in this set are included; null means all
+     * @param matcher         selects the spans the span set lists, null lists every span of the trace
+     * @param spansPerSpanSet the most spans the span set lists; zero or less lists all matches
      * @return SearchResponse.Trace
      */
     private static SearchResponse.Trace convertSWTraceToSearchTrace(
-        org.apache.skywalking.oap.server.core.query.type.trace.v2.TraceV2 swTrace, Set<String> allowedTags) {
+        TraceV2 swTrace, Set<String> allowedTags,
+        SkyWalkingSpanMatcher matcher, int spansPerSpanSet) {
 
         SearchResponse.Trace trace = new SearchResponse.Trace();
 
@@ -329,13 +430,35 @@ public class SkyWalkingOTLPConverter {
         trace.setStartTimeUnixNano(String.valueOf(minStartTime * 1_000_000));
         trace.setDurationMs((int) (maxEndTime - minStartTime));
 
-        // First pass: collect all attribute keys across all spans.
+        for (org.apache.skywalking.oap.server.core.query.type.Span span : swTrace.getSpans()) {
+            SearchResponse.ServiceStat stat = trace.getServiceStats()
+                                                   .computeIfAbsent(span.getServiceCode(), k -> new SearchResponse.ServiceStat(0, 0));
+            stat.setSpanCount(stat.getSpanCount() + 1);
+            if (span.isError()) {
+                stat.setErrorCount(stat.getErrorCount() + 1);
+            }
+        }
+
+        List<org.apache.skywalking.oap.server.core.query.type.Span> matched = swTrace.getSpans();
+        if (matcher != null) {
+            matched = swTrace.getSpans().stream().filter(matcher::matches).collect(Collectors.toList());
+            if (matched.isEmpty()) {
+                // The storage matched the segment on conditions it evaluates per segment, tags and duration among
+                // them, while TraceQL asks for one span meeting all of them; leaving the trace out is the strict
+                // answer, listing spans the query excluded is the failure #14093 describes.
+                return null;
+            }
+        }
+        List<org.apache.skywalking.oap.server.core.query.type.Span> listed = spansPerSpanSet > 0 && matched.size() > spansPerSpanSet
+            ? matched.subList(0, spansPerSpanSet) : matched;
+
+        // First pass: collect all attribute keys across the listed spans.
         // Grafana has a bug (search.go:369) where spans missing an attribute key that other
         // spans have cause a type panic: Append("") on a []*string field instead of Append(nil).
         // By ensuring all spans have the same keys (padding missing ones with ""), we avoid
         // the else branch in Grafana entirely.
         Set<String> allSpanAttrKeys = new LinkedHashSet<>();
-        for (org.apache.skywalking.oap.server.core.query.type.Span span : swTrace.getSpans()) {
+        for (org.apache.skywalking.oap.server.core.query.type.Span span : listed) {
             for (org.apache.skywalking.oap.server.core.query.type.KeyValue tag : span.getTags()) {
                 allSpanAttrKeys.add(tag.getKey());
             }
@@ -347,11 +470,12 @@ public class SkyWalkingOTLPConverter {
         // Add fixed tags
         allSpanAttrKeys.add(SPAN_KIND);
         allSpanAttrKeys.add(SERVICE_NAME);
+        allSpanAttrKeys.add(STATUS);
         SearchResponse.SpanSet spanSet = new SearchResponse.SpanSet();
-        for (org.apache.skywalking.oap.server.core.query.type.Span span : swTrace.getSpans()) {
+        for (org.apache.skywalking.oap.server.core.query.type.Span span : listed) {
             spanSet.getSpans().add(convertSWSpanToSearchSpan(span, allSpanAttrKeys));
         }
-        spanSet.setMatched(spanSet.getSpans().size());
+        spanSet.setMatched(matched.size());
         trace.getSpanSets().add(spanSet);
 
         return trace;
@@ -386,6 +510,8 @@ public class SkyWalkingOTLPConverter {
         for (org.apache.skywalking.oap.server.core.query.type.KeyValue tag : swSpan.getTags()) {
             spanAttrMap.put(tag.getKey(), tag.getValue());
         }
+        // The span status as a fixed attribute, so a trace list can show failures without opening each trace (#14093).
+        spanAttrMap.put(STATUS, swSpan.isError() ? ERROR : OK);
 
         // Output all keys in consistent order, padding missing keys with "" to avoid
         // the Grafana search.go:369 type panic on []*string fields
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingSpanMatcher.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingSpanMatcher.java
new file mode 100644
index 0000000000..6b82c94999
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingSpanMatcher.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParams;
+import org.apache.skywalking.oap.server.core.query.type.KeyValue;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.query.type.Span;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.ALL;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.ERROR;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.HTTP_STATUS_CODE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.OK;
+
+/**
+ * Re-evaluates the TraceQL parameters against one span of a SkyWalking trace, so a search result lists the spans
+ * that matched instead of every span of the matched trace, as Tempo does. The conditions mirror what
+ * {@code SkyWalkingTraceQLApiHandler} puts into the {@code TraceQueryCondition}: service, instance, endpoint,
+ * duration range, error state and tag equality, including {@code http.status_code}.
+ */
+public final class SkyWalkingSpanMatcher {
+    private final TraceQLQueryParams params;
+    private final NamingControl namingControl;
+    private final Set<String> tags = new HashSet<>();
+
+    /**
+     * Compares the raw operation names; for tests. Production code passes the {@link NamingControl}.
+     */
+    public SkyWalkingSpanMatcher(final TraceQLQueryParams params) {
+        this(params, null);
+    }
+
+    /**
+     * @param namingControl the rules the trace analyzer applied to the endpoint name the storage matched, while the
+     *                      span keeps the raw operation name; null compares the raw names
+     */
+    public SkyWalkingSpanMatcher(final TraceQLQueryParams params, final NamingControl namingControl) {
+        this.params = params;
+        this.namingControl = namingControl;
+        if (params.getTags() != null) {
+            for (final Map.Entry<String, String> tag : params.flatTags().entrySet()) {
+                tags.add(tag.getKey() + "=" + tag.getValue());
+            }
+        }
+        if (StringUtil.isNotBlank(params.getHttpStatusCode())) {
+            tags.add(HTTP_STATUS_CODE + "=" + params.getHttpStatusCode());
+        }
+    }
+
+    public boolean matches(final Span span) {
+        if (differs(params.getServiceName(), span.getServiceCode())
+            || differs(params.getServiceInstance(), span.getServiceInstanceName())
+            || differs(params.getSpanName(), endpointName(span))) {
+            return false;
+        }
+        final long durationMillis = span.getEndTime() - span.getStartTime();
+        if (params.getMinDuration() != null && durationMillis < TimeUnit.MICROSECONDS.toMillis(params.getMinDuration())) {
+            return false;
+        }
+        if (params.getMaxDuration() != null && durationMillis > TimeUnit.MICROSECONDS.toMillis(params.getMaxDuration())) {
+            return false;
+        }
+        if (ERROR.equalsIgnoreCase(params.getStatus()) && !span.isError()) {
+            return false;
+        }
+        if (OK.equalsIgnoreCase(params.getStatus()) && span.isError()) {
+            return false;
+        }
+        if (tags.isEmpty()) {
+            return true;
+        }
+        final Set<String> rendered = new HashSet<>();
+        for (final KeyValue tag : span.getTags()) {
+            rendered.add(tag.getKey() + "=" + tag.getValue());
+        }
+        return rendered.containsAll(tags);
+    }
+
+    /**
+     * A TraceQL value of {@code *} matches anything, as the handler treats it when building the condition.
+     */
+    private String endpointName(final Span span) {
+        return namingControl == null
+            ? span.getEndpointName() : namingControl.formatEndpointName(span.getServiceCode(), span.getEndpointName());
+    }
+
+    private static boolean differs(final String expected, final String actual) {
+        return StringUtil.isNotBlank(expected) && !ALL.equals(expected) && !expected.equals(actual);
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinOTLPConverter.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinOTLPConverter.java
index 10e09580f0..e7652bab36 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinOTLPConverter.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinOTLPConverter.java
@@ -34,15 +34,27 @@ import io.opentelemetry.proto.trace.v1.Status;
 import org.apache.skywalking.oap.query.traceql.entity.SearchResponse;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 import org.apache.skywalking.oap.server.library.util.StringUtil;
 
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.ERROR;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.NAME;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.REMOTE_SERVICE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.RESOURCE_REMOTE_SERVICE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.RESOURCE_SERVICE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.RESOURCE_SERVICE_NAME;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SCOPE_RESOURCE;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SERVICE;
 import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SERVICE_NAME;
 import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SPAN_KIND;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.SPAN_PREFIX;
+import static org.apache.skywalking.oap.query.traceql.handler.TraceQLApiHandler.STATUS;
 
 /**
  * Converter for transforming Zipkin trace data to OpenTelemetry Protocol (OTLP) format.
@@ -274,15 +286,82 @@ public class ZipkinOTLPConverter {
                                 .build();
     }
 
+    /**
+     * The tag keys of one scope on the spans of the sampled traces that the matcher accepts, for Tempo's filtered
+     * {@code /api/v2/search/tags}. Zipkin spans have no resource, so that scope keeps its fixed names.
+     */
+    public static List<String> tagNames(List<List<zipkin2.Span>> traces, ZipkinSpanMatcher matcher, String scope) {
+        if (SCOPE_RESOURCE.equals(scope)) {
+            return Arrays.asList(SERVICE, REMOTE_SERVICE);
+        }
+        Set<String> keys = new LinkedHashSet<>();
+        for (zipkin2.Span span : matched(traces, matcher)) {
+            keys.addAll(span.tags().keySet());
+        }
+        return new ArrayList<>(keys);
+    }
+
+    /**
+     * The distinct values of one tag on the spans of the sampled traces that the matcher accepts, for Tempo's
+     * filtered {@code /api/v2/search/tag/{tag}/values}. {@code tag} is the normalized name the handler switches on.
+     */
+    public static List<String> tagValues(List<List<zipkin2.Span>> traces, ZipkinSpanMatcher matcher, String tag) {
+        Set<String> values = new LinkedHashSet<>();
+        for (zipkin2.Span span : matched(traces, matcher)) {
+            String value = tagValue(span, tag);
+            if (StringUtil.isNotEmpty(value)) {
+                values.add(value);
+            }
+        }
+        return new ArrayList<>(values);
+    }
+
+    private static String tagValue(zipkin2.Span span, String tag) {
+        switch (tag) {
+            case RESOURCE_SERVICE_NAME:
+            case RESOURCE_SERVICE:
+                return span.localServiceName();
+            case RESOURCE_REMOTE_SERVICE:
+                return span.remoteServiceName();
+            case NAME:
+                return span.name();
+            case STATUS:
+                // Zipkin's error convention: an "error" tag, whatever its value.
+                return span.tags().containsKey(ERROR) ? ERROR : null;
+            default:
+                if (tag.startsWith(SPAN_PREFIX)) {
+                    return span.tags().get(tag.substring(SPAN_PREFIX.length()));
+                }
+                return null;
+        }
+    }
+
+    private static List<zipkin2.Span> matched(List<List<zipkin2.Span>> traces, ZipkinSpanMatcher matcher) {
+        List<zipkin2.Span> spans = new ArrayList<>();
+        for (List<zipkin2.Span> trace : traces) {
+            for (zipkin2.Span span : trace) {
+                if (matcher == null || matcher.matches(span)) {
+                    spans.add(span);
+                }
+            }
+        }
+        return spans;
+    }
+
     /**
      * Convert Zipkin traces to SearchResponse.
      * Each trace in the list becomes a Trace in the SearchResponse.
      *
-     * @param traces      List of Zipkin trace (each trace is a list of spans)
-     * @param allowedTags Only span attributes whose key is in this set are included in the result
+     * @param traces          List of Zipkin trace (each trace is a list of spans)
+     * @param allowedTags     Only span attributes whose key is in this set are included in the result
+     * @param matcher         selects the spans the span set lists, null lists every span of the trace
+     * @param spansPerSpanSet Tempo's {@code spss}, the most spans a span set lists; zero or less lists all matches
      * @return SearchResponse containing the converted traces
      */
-    public static SearchResponse convertToSearchResponse(List<List<zipkin2.Span>> traces, Set<String> allowedTags) {
+    public static SearchResponse convertToSearchResponse(List<List<zipkin2.Span>> traces,
+                                                         Set<String> allowedTags,
+                                                         ZipkinSpanMatcher matcher,
+                                                         int spansPerSpanSet) {
         SearchResponse response = new SearchResponse();
 
         if (traces == null || traces.isEmpty()) {
@@ -294,8 +373,10 @@ public class ZipkinOTLPConverter {
                 continue;
             }
 
-            SearchResponse.Trace trace = convertZipkinTraceToSearchTrace(zipkinTrace, allowedTags);
-            response.getTraces().add(trace);
+            SearchResponse.Trace trace = convertZipkinTraceToSearchTrace(zipkinTrace, allowedTags, matcher, spansPerSpanSet);
+            if (trace != null) {
+                response.getTraces().add(trace);
+            }
         }
 
         return response;
@@ -304,12 +385,16 @@ public class ZipkinOTLPConverter {
     /**
      * Convert a single Zipkin trace (list of spans) to SearchResponse.Trace.
      *
-     * @param zipkinTrace List of Zipkin spans representing one trace
-     * @param allowedTags Only span attributes whose key is in this set are included; null means all
+     * @param zipkinTrace     List of Zipkin spans representing one trace
+     * @param allowedTags     Only span attributes whose key is in this set are included; null means all
+     * @param matcher         selects the spans the span set lists, null lists every span of the trace
+     * @param spansPerSpanSet the most spans the span set lists; zero or less lists all matches
      * @return SearchResponse.Trace
      */
     private static SearchResponse.Trace convertZipkinTraceToSearchTrace(List<zipkin2.Span> zipkinTrace,
-                                                                         Set<String> allowedTags) {
+                                                                         Set<String> allowedTags,
+                                                                         ZipkinSpanMatcher matcher,
+                                                                         int spansPerSpanSet) {
         SearchResponse.Trace trace = new SearchResponse.Trace();
 
         if (zipkinTrace.isEmpty()) {
@@ -344,13 +429,37 @@ public class ZipkinOTLPConverter {
         trace.setStartTimeUnixNano(String.valueOf(TimeUnit.MICROSECONDS.toNanos(minStartTime)));
         trace.setDurationMs((int) TimeUnit.MICROSECONDS.toMillis(maxEndTime - minStartTime));
 
-        // First pass: collect all attribute keys across all spans.
+        for (zipkin2.Span zipkinSpan : zipkinTrace) {
+            String service = zipkinSpan.localServiceName() != null ? zipkinSpan.localServiceName() : "unknown-service";
+            SearchResponse.ServiceStat stat = trace.getServiceStats()
+                                                   .computeIfAbsent(service, k -> new SearchResponse.ServiceStat(0, 0));
+            stat.setSpanCount(stat.getSpanCount() + 1);
+            // Zipkin's error convention: an "error" tag, whatever its value.
+            if (zipkinSpan.tags() != null && zipkinSpan.tags().containsKey(ERROR)) {
+                stat.setErrorCount(stat.getErrorCount() + 1);
+            }
+        }
+
+        List<zipkin2.Span> matched = zipkinTrace;
+        if (matcher != null) {
+            matched = zipkinTrace.stream().filter(matcher::matches).collect(Collectors.toList());
+            if (matched.isEmpty()) {
+                // Zipkin's annotationQuery is satisfied by tags spread over several spans of the trace, while TraceQL
+                // asks for one span carrying all of them; leaving the trace out is the strict answer, listing spans
+                // the query excluded is the failure #14093 describes.
+                return null;
+            }
+        }
+        List<zipkin2.Span> listed = spansPerSpanSet > 0 && matched.size() > spansPerSpanSet
+            ? matched.subList(0, spansPerSpanSet) : matched;
+
+        // First pass: collect all attribute keys across the listed spans.
         // Grafana has a bug (search.go:369) where spans missing an attribute key that other
         // spans have cause a type panic: Append("") on a []*string field instead of Append(nil).
         // By ensuring all spans have the same keys (padding missing ones with ""), we avoid
         // the else branch in Grafana entirely.
         Set<String> allSpanAttrKeys = new LinkedHashSet<>();
-        for (zipkin2.Span zipkinSpan : zipkinTrace) {
+        for (zipkin2.Span zipkinSpan : listed) {
             if (zipkinSpan.tags() != null) {
                 allSpanAttrKeys.addAll(zipkinSpan.tags().keySet());
             }
@@ -362,12 +471,13 @@ public class ZipkinOTLPConverter {
         // Add fixed tags last so they always appear
         allSpanAttrKeys.add(SERVICE_NAME);
         allSpanAttrKeys.add(SPAN_KIND);
+        allSpanAttrKeys.add(STATUS);
 
         SearchResponse.SpanSet spanSet = new SearchResponse.SpanSet();
-        for (zipkin2.Span zipkinSpan : zipkinTrace) {
+        for (zipkin2.Span zipkinSpan : listed) {
             spanSet.getSpans().add(convertZipkinSpanToSearchSpan(zipkinSpan, allSpanAttrKeys));
         }
-        spanSet.setMatched(spanSet.getSpans().size());
+        spanSet.setMatched(matched.size());
         trace.getSpanSets().add(spanSet);
 
         return trace;
@@ -408,6 +518,8 @@ public class ZipkinOTLPConverter {
         if (zipkinSpan.tags() != null) {
             spanAttrMap.putAll(zipkinSpan.tags());
         }
+        // The span status as a fixed attribute, Zipkin's error convention: an "error" tag, whatever its value (#14093).
+        spanAttrMap.put(STATUS, zipkinSpan.tags() != null && zipkinSpan.tags().containsKey(ERROR) ? ERROR : "unset");
 
         // Output all keys in consistent order, padding missing keys with "" to avoid
         // the Grafana search.go:369 type panic on []*string fields
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinSpanMatcher.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinSpanMatcher.java
new file mode 100644
index 0000000000..c4187cfcb5
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinSpanMatcher.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import java.util.Map;
+import zipkin2.Annotation;
+import zipkin2.Span;
+import zipkin2.storage.QueryRequest;
+
+/**
+ * Re-evaluates a Zipkin {@link QueryRequest} against one span, so a search result lists the spans that matched
+ * instead of every span of the matched trace, as Tempo does. The conditions are the ones the Zipkin query DAOs
+ * apply per span: local and remote service, span name, the annotation query over tag keys, {@code key=value}
+ * tags and annotation values, and the duration range.
+ */
+public final class ZipkinSpanMatcher {
+    private final QueryRequest request;
+
+    public ZipkinSpanMatcher(final QueryRequest request) {
+        this.request = request;
+    }
+
+    public boolean matches(final Span span) {
+        if (request.serviceName() != null && !request.serviceName().equals(span.localServiceName())) {
+            return false;
+        }
+        if (request.remoteServiceName() != null && !request.remoteServiceName().equals(span.remoteServiceName())) {
+            return false;
+        }
+        if (request.spanName() != null && !request.spanName().equals(span.name())) {
+            return false;
+        }
+        if (request.minDuration() != null && span.durationAsLong() < request.minDuration()) {
+            return false;
+        }
+        if (request.maxDuration() != null && span.durationAsLong() > request.maxDuration()) {
+            return false;
+        }
+        for (final Map.Entry<String, String> condition : request.annotationQuery().entrySet()) {
+            if (condition.getValue().isEmpty()) {
+                if (!span.tags().containsKey(condition.getKey()) && !hasAnnotation(span, condition.getKey())) {
+                    return false;
+                }
+            } else if (!condition.getValue().equals(span.tags().get(condition.getKey()))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean hasAnnotation(final Span span, final String value) {
+        for (final Annotation annotation : span.annotations()) {
+            if (value.equals(annotation.value())) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/entity/SearchResponse.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/entity/SearchResponse.java
index 4bd418eb9f..af4bde8182 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/entity/SearchResponse.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/entity/SearchResponse.java
@@ -20,7 +20,9 @@ package org.apache.skywalking.oap.query.traceql.entity;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.NoArgsConstructor;
@@ -37,6 +39,12 @@ public class SearchResponse extends QueryResponse {
         private String startTimeUnixNano;
         private Integer durationMs;
         private List<SpanSet> spanSets = new ArrayList<>();
+        /**
+         * Span and error counts per service over the whole trace, filled by every datasource; omitted when a trace
+         * has no spans to count.
+         */
+        @JsonInclude(JsonInclude.Include.NON_EMPTY)
+        private Map<String, ServiceStat> serviceStats = new LinkedHashMap<>();
     }
 
     @Data
@@ -59,10 +67,18 @@ public class SearchResponse extends QueryResponse {
         private Value value;
     }
 
+    /**
+     * One of the fields is set, following the OTLP/JSON {@code AnyValue} encoding: {@code intValue} is a decimal
+     * string because proto3 JSON renders int64 as a string. The Zipkin and SkyWalking datasources set
+     * {@code stringValue} only; the OTLP datasource keeps the attribute's type.
+     */
     @Data
     @JsonInclude(JsonInclude.Include.NON_NULL)
     public static class Value {
         private String stringValue;
+        private String intValue;
+        private Boolean boolValue;
+        private Double doubleValue;
     }
 
     @Data
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/OTLPTraceQLApiHandler.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/OTLPTraceQLApiHandler.java
new file mode 100644
index 0000000000..17b10c389a
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/OTLPTraceQLApiHandler.java
@@ -0,0 +1,414 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.query.traceql.handler;
+
+import com.linecorp.armeria.common.HttpData;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.common.ResponseHeaders;
+import io.grafana.tempo.tempopb.TraceByIDResponse;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
+import io.opentelemetry.proto.trace.v1.Span;
+import io.opentelemetry.proto.trace.v1.Status;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.skywalking.oap.query.traceql.TraceQLConfig;
+import org.apache.skywalking.oap.query.traceql.converter.OTLPSpanMatcher;
+import org.apache.skywalking.oap.query.traceql.converter.OTLPTraceAssembler;
+import org.apache.skywalking.oap.query.traceql.entity.SearchResponse;
+import org.apache.skywalking.oap.query.traceql.entity.TagValuesResponse;
+import org.apache.skywalking.oap.query.traceql.exception.IllegalExpressionException;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLParseResult;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParams;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParser;
+import org.apache.skywalking.oap.server.core.Const;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.TagType;
+import org.apache.skywalking.oap.server.core.query.OTLPTraceQueryService;
+import org.apache.skywalking.oap.server.core.query.TagAutoCompleteQueryService;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.input.OTLPTraceQueryCondition;
+import org.apache.skywalking.oap.server.core.storage.query.proto.SpanWrapper;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+import static org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryVisitor.parseDuration;
+
+/**
+ * The Tempo API over natively stored OTLP spans. Nothing is converted between formats: the search maps TraceQL
+ * onto the index columns of {@code otlp_span}, and a trace is returned as the {@code ResourceSpans} the exporters
+ * sent, regrouped into one message per resource and scope.
+ */
+public class OTLPTraceQLApiHandler extends TraceQLApiHandler {
+    /**
+     * TraceQL spellings of the OTLP span kinds, in enum-number order.
+     */
+    private static final List<String> KINDS = Arrays.asList(
+        "unspecified", "internal", "server", "client", "producer", "consumer");
+    private static final String UNSET = "unset";
+    private static final List<String> INTRINSIC_TAG_NAMES = Arrays.asList(
+        NAME, STATUS, KIND, DURATION,
+        SPAN_INTRINSIC_PREFIX + NAME, SPAN_INTRINSIC_PREFIX + STATUS, SPAN_INTRINSIC_PREFIX + KIND, SPAN_INTRINSIC_PREFIX + DURATION);
+    private static final String OTEL_SCOPE_NAME = OTLPTraceAssembler.OTEL_SCOPE_NAME;
+    private static final String SPAN_OTEL_SCOPE_NAME = SPAN_PREFIX + OTEL_SCOPE_NAME;
+    private static final String RESOURCE_SERVICE_INSTANCE_ID = SCOPE_RESOURCE + Const.POINT + SERVICE_INSTANCE_ID;
+    private static final String PEER_SERVICE = "peer.service";
+    private static final int DEFAULT_LIMIT = 20;
+
+    private final OTLPTraceQueryService queryService;
+    private final NamingControl namingControl;
+    private final TagAutoCompleteQueryService tagAutoCompleteQueryService;
+    private final TraceQLConfig config;
+    private final Set<String> allowedTags;
+
+    public OTLPTraceQLApiHandler(final ModuleManager moduleManager, final TraceQLConfig config) {
+        this.queryService = new OTLPTraceQueryService(moduleManager);
+        this.namingControl = moduleManager.find(CoreModule.NAME).provider().getService(NamingControl.class);
+        this.tagAutoCompleteQueryService = moduleManager.find(CoreModule.NAME)
+                                                        .provider()
+                                                        .getService(TagAutoCompleteQueryService.class);
+        this.config = config;
+        final Set<String> allowedTagsInit = new HashSet<>();
+        if (StringUtil.isNotBlank(config.getOtlpTracesListResultTags())) {
+            allowedTagsInit.addAll(Arrays.stream(config.getOtlpTracesListResultTags().split(Const.COMMA))
+                                         .map(String::trim)
+                                         .filter(tag -> !tag.isEmpty())
+                                         .collect(Collectors.toList()));
+        }
+        allowedTagsInit.add(SPAN_KIND);
+        allowedTagsInit.add(SERVICE_NAME);
+        this.allowedTags = allowedTagsInit;
+    }
+
+    @Override
+    protected HttpResponse queryTraceImpl(final String traceId,
+                                          final Optional<Long> start,
+                                          final Optional<Long> end,
+                                          final Optional<Boolean> coldStage,
+                                          final Optional<String> accept) throws IOException {
+        // Without a range the storage searches everything it retains, as the Zipkin by-id lookup does.
+        Duration duration = null;
+        if (start.isPresent() || end.isPresent() || coldStage.orElse(false)) {
+            duration = buildDuration(start, end, config.getLookback());
+            duration.setColdStage(coldStage.orElse(false));
+        }
+        final List<SpanWrapper> wrappers = queryService.queryTraceById(traceId.trim().toLowerCase(Locale.ROOT), duration);
+        if (wrappers.isEmpty()) {
+            return HttpResponse.of(HttpStatus.NOT_FOUND);
+        }
+        final TraceByIDResponse response = OTLPTraceAssembler.assemble(OTLPTraceAssembler.decode(wrappers));
+        if (accept.isPresent() && accept.get().contains("application/protobuf")) {
+            return buildProtobufHttpResponse(response);
+        }
+        return HttpResponse.of(
+            ResponseHeaders.builder(HttpStatus.OK).contentType(MediaType.JSON).build(),
+            HttpData.ofUtf8(OTLPTraceAssembler.toJson(response))
+        );
+    }
+
+    @Override
+    protected HttpResponse searchImpl(final Optional<String> query,
+                                      final Optional<String> tags,
+                                      final Optional<String> minDuration,
+                                      final Optional<String> maxDuration,
+                                      final Optional<Integer> limit,
+                                      final Optional<Long> start,
+                                      final Optional<Long> end,
+                                      final Optional<Integer> spss,
+                                      final Optional<Boolean> coldStage) throws IOException {
+        try {
+            final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+            final Duration duration = buildDuration(start, end, config.getLookback());
+            duration.setColdStage(coldStage.orElse(false));
+            condition.setQueryDuration(duration);
+            condition.setLimit(limit.orElse(DEFAULT_LIMIT));
+
+            if (query.isPresent() && !query.get().isEmpty()) {
+                final TraceQLParseResult parseResult = TraceQLQueryParser.extractParams(query.get());
+                if (parseResult.hasError()) {
+                    return badRequestResponse(parseResult.getErrorInfo());
+                }
+                applyTraceQL(parseResult.getParams(), condition);
+            } else {
+                if (tags.isPresent() && !tags.get().isEmpty()) {
+                    applyTraceQL(parseTagsParameter(tags.get()), condition);
+                }
+            }
+            if (condition.getMinDurationNanos() == null && minDuration.isPresent()) {
+                condition.setMinDurationNanos(parseDuration(minDuration.get()) * 1000L);
+            }
+            if (condition.getMaxDurationNanos() == null && maxDuration.isPresent()) {
+                condition.setMaxDurationNanos(parseDuration(maxDuration.get()) * 1000L);
+            }
+
+            final List<List<SpanWrapper>> wrapped = queryService.queryTraces(condition);
+            final List<List<ResourceSpans>> traces = new ArrayList<>(wrapped.size());
+            for (final List<SpanWrapper> trace : wrapped) {
+                traces.add(OTLPTraceAssembler.decode(trace));
+            }
+            final SearchResponse response = OTLPTraceAssembler.toSearchResponse(
+                traces, allowedTags, new OTLPSpanMatcher(condition, namingControl), spansPerSpanSet(spss));
+            return successResponse(response);
+        } catch (IllegalExpressionException | IllegalArgumentException e) {
+            return badRequestResponse(e.getMessage());
+        }
+    }
+
+    /**
+     * TraceQL onto the index columns, with no attribute renaming: the named fields go to their columns, every
+     * other {@code span.x}, {@code resource.x} or unscoped {@code x} becomes an equality condition on the
+     * {@code key=value} index, as on the Zipkin datasource.
+     */
+    private void applyTraceQL(final TraceQLQueryParams params, final OTLPTraceQueryCondition condition) {
+        if (StringUtil.isNotBlank(params.getServiceName()) && !ALL.equals(params.getServiceName())) {
+            condition.setServiceName(params.getServiceName());
+        }
+        if (StringUtil.isNotBlank(params.getRemoteServiceName()) && !ALL.equals(params.getRemoteServiceName())) {
+            condition.setPeerService(params.getRemoteServiceName());
+        }
+        if (StringUtil.isNotBlank(params.getServiceInstance()) && !ALL.equals(params.getServiceInstance())) {
+            condition.setServiceInstance(params.getServiceInstance());
+        }
+        if (StringUtil.isNotBlank(params.getSpanName()) && !ALL.equals(params.getSpanName())) {
+            condition.setSpanName(params.getSpanName());
+        }
+        if (params.getMinDuration() != null) {
+            condition.setMinDurationNanos(params.getMinDuration() * 1000L);
+        }
+        if (params.getMaxDuration() != null) {
+            condition.setMaxDurationNanos(params.getMaxDuration() * 1000L);
+        }
+        if (StringUtil.isNotBlank(params.getStatus())) {
+            condition.setStatusCode(statusCode(params.getStatus()));
+        }
+        if (StringUtil.isNotBlank(params.getKind())) {
+            condition.setKind(kind(params.getKind()));
+        }
+        if (StringUtil.isNotBlank(params.getHttpStatusCode())) {
+            condition.getTags().add(new Tag(HTTP_STATUS_CODE, params.getHttpStatusCode()));
+        }
+        for (final Map.Entry<String, String> tag : params.getTags().entrySet()) {
+            // the column-backed keys are matched without their scope, every other key keeps it for the tag index
+            switch (unscoped(tag.getKey())) {
+                case OTEL_SCOPE_NAME:
+                    condition.setScopeName(tag.getValue());
+                    break;
+                case SERVICE_INSTANCE_ID:
+                    condition.setServiceInstance(tag.getValue());
+                    break;
+                case PEER_SERVICE:
+                    condition.setPeerService(tag.getValue());
+                    break;
+                default:
+                    condition.getTags().add(new Tag(tag.getKey(), tag.getValue()));
+            }
+        }
+    }
+
+    private static String unscoped(final String key) {
+        if (key.startsWith(SPAN_PREFIX)) {
+            return key.substring(SPAN_PREFIX.length());
+        }
+        if (key.startsWith(SCOPE_RESOURCE + ".")) {
+            return key.substring(SCOPE_RESOURCE.length() + 1);
+        }
+        return key;
+    }
+
+    private static int statusCode(final String status) {
+        switch (status.toLowerCase(Locale.ROOT)) {
+            case ERROR:
+                return Status.StatusCode.STATUS_CODE_ERROR.getNumber();
+            case OK:
+                return Status.StatusCode.STATUS_CODE_OK.getNumber();
+            case UNSET:
+                return Status.StatusCode.STATUS_CODE_UNSET.getNumber();
+            default:
+                throw new IllegalArgumentException("Unsupported status: " + status + ", expected error, ok or unset.");
+        }
+    }
+
+    private static int kind(final String kind) {
+        final int number = KINDS.indexOf(kind.toLowerCase(Locale.ROOT));
+        if (number < 0) {
+            throw new IllegalArgumentException("Unsupported kind: " + kind + ", expected one of " + KINDS + ".");
+        }
+        return Span.SpanKind.forNumber(number).getNumber();
+    }
+
+    @Override
+    protected HttpResponse searchTagsImpl(final Optional<String> scope,
+                                          final Optional<Integer> limit,
+                                          final Optional<Long> start,
+                                          final Optional<Long> end) throws IOException {
+        final Duration duration = buildDuration(start, end, config.getLookback());
+        return tagNames(scope, limit, requested -> namesOf(requested, duration));
+    }
+
+    @Override
+    protected HttpResponse searchTagsV2Impl(final Optional<String> q,
+                                            final Optional<String> scope,
+                                            final Optional<Integer> limit,
+                                            final Optional<Long> start,
+                                            final Optional<Long> end) throws IOException {
+        final Duration duration = buildDuration(start, end, config.getLookback());
+        final TraceQLParseResult filter = parseFilter(q);
+        if (filter != null && filter.hasError()) {
+            return badRequestResponse(filter.getErrorInfo());
+        }
+        if (filter == null || !hasFilter(filter.getParams())) {
+            return tagNamesV2(scope, limit, requested -> namesOf(requested, duration));
+        }
+        try {
+            final Sample sample = sample(filter.getParams(), duration);
+            return tagNamesV2(scope, limit, requested -> SCOPE_INTRINSIC.equals(requested)
+                ? INTRINSIC_TAG_NAMES : OTLPTraceAssembler.tagNames(sample.traces, sample.matcher, requested));
+        } catch (IllegalArgumentException e) {
+            return badRequestResponse(e.getMessage());
+        }
+    }
+
+    private List<String> namesOf(final String scope, final Duration duration) throws IOException {
+        switch (scope) {
+            case SCOPE_RESOURCE:
+                return Arrays.asList(SERVICE_NAME, SERVICE_INSTANCE_ID, REMOTE_SERVICE);
+            case SCOPE_SPAN:
+                return new ArrayList<>(tagAutoCompleteQueryService.queryTagAutocompleteKeys(TagType.OTLP, duration));
+            default:
+                return INTRINSIC_TAG_NAMES;
+        }
+    }
+
+    /**
+     * Tempo's {@code q} on a tag lookup: the newest {@link #TAG_FILTER_SAMPLE_TRACES} traces matching the filter,
+     * with the matcher that picks their matching spans.
+     */
+    private Sample sample(final TraceQLQueryParams params, final Duration duration) throws IOException {
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+        condition.setQueryDuration(duration);
+        condition.setLimit(TAG_FILTER_SAMPLE_TRACES);
+        applyTraceQL(params, condition);
+        final List<List<ResourceSpans>> traces = new ArrayList<>();
+        for (final List<SpanWrapper> trace : queryService.queryTraces(condition)) {
+            traces.add(OTLPTraceAssembler.decode(trace));
+        }
+        return new Sample(traces, new OTLPSpanMatcher(condition, namingControl));
+    }
+
+    private static final class Sample {
+        private final List<List<ResourceSpans>> traces;
+        private final OTLPSpanMatcher matcher;
+
+        private Sample(final List<List<ResourceSpans>> traces, final OTLPSpanMatcher matcher) {
+            this.traces = traces;
+            this.matcher = matcher;
+        }
+    }
+
+    @Override
+    protected HttpResponse searchTagValuesImpl(final String tagName,
+                                               final Optional<String> query,
+                                               final Optional<Integer> limit,
+                                               final Optional<Long> start,
+                                               final Optional<Long> end) throws IOException {
+        final Duration duration = buildDuration(start, end, config.getLookback());
+        final String tag = normalizeTagName(tagName);
+        final TraceQLParseResult filter = parseFilter(query);
+        if (filter != null && filter.hasError()) {
+            return badRequestResponse(filter.getErrorInfo());
+        }
+        // Anything narrower than a service name is answered from a sample of matching traces; the service-scoped
+        // catalogs below are exact and complete, so they keep answering the common service-only case.
+        if (filter != null && hasFilter(filter.getParams()) && !isEnumIntrinsic(tag)
+            && !(onlyServiceName(filter.getParams()) && (NAME.equals(tag) || RESOURCE_REMOTE_SERVICE.equals(tag)))) {
+            try {
+                final Sample sample = sample(filter.getParams(), duration);
+                return successResponse(stringValues(OTLPTraceAssembler.tagValues(sample.traces, sample.matcher, tag), limit));
+            } catch (IllegalArgumentException e) {
+                return badRequestResponse(e.getMessage());
+            }
+        }
+        switch (tag) {
+            case RESOURCE_SERVICE_NAME:
+            case RESOURCE_SERVICE:
+            case SERVICE_NAME:
+                return successResponse(stringValues(queryService.getServiceNames(duration), limit));
+            case RESOURCE_SERVICE_INSTANCE_ID:
+            case RESOURCE_INSTANCE:
+                return successResponse(stringValues(
+                    tagAutoCompleteQueryService.queryTagAutocompleteValues(TagType.OTLP, SERVICE_INSTANCE_ID, duration), limit));
+            case STATUS:
+                return successResponse(stringValues(Arrays.asList(ERROR, OK, UNSET), limit));
+            case KIND:
+                return successResponse(stringValues(KINDS, limit));
+            case DURATION:
+                // A range intrinsic has no value list, Tempo answers an empty one too.
+                return successResponse(new TagValuesResponse());
+            case OTEL_SCOPE_NAME:
+            case SPAN_OTEL_SCOPE_NAME:
+                return successResponse(stringValues(
+                    tagAutoCompleteQueryService.queryTagAutocompleteValues(TagType.OTLP, OTEL_SCOPE_NAME, duration), limit));
+            case NAME:
+            case RESOURCE_REMOTE_SERVICE:
+                return scopedValues(tag, query, duration, limit);
+            default:
+                if (tag.startsWith(SPAN_PREFIX)) {
+                    return successResponse(stringValues(tagAutoCompleteQueryService.queryTagAutocompleteValues(
+                        TagType.OTLP, tag.substring(SPAN_PREFIX.length()), duration), limit));
+                }
+                return badRequestResponse("Unsupported tag value query.");
+        }
+    }
+
+    /**
+     * Span names and remote services are listed per service, so they need a {@code q} naming the service; without
+     * one the list is empty rather than an error, because Grafana asks for these on every visit to the query page.
+     */
+    private HttpResponse scopedValues(final String tagName,
+                                      final Optional<String> query,
+                                      final Duration duration,
+                                      final Optional<Integer> limit) throws IOException {
+        if (!query.isPresent() || query.get().isEmpty()) {
+            return successResponse(new TagValuesResponse());
+        }
+        final TraceQLParseResult parseResult = TraceQLQueryParser.extractParams(query.get());
+        if (parseResult.hasError()) {
+            return badRequestResponse(parseResult.getErrorInfo());
+        }
+        final String serviceName = parseResult.getParams().getServiceName();
+        if (StringUtil.isBlank(serviceName) || ALL.equals(serviceName)) {
+            return successResponse(new TagValuesResponse());
+        }
+        if (NAME.equals(tagName)) {
+            return successResponse(stringValues(queryService.getSpanNames(serviceName, duration), limit));
+        }
+        return successResponse(stringValues(queryService.getPeerServiceNames(serviceName, duration), limit));
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/SkyWalkingTraceQLApiHandler.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/SkyWalkingTraceQLApiHandler.java
index 2e472b7bd0..110b1380cf 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/SkyWalkingTraceQLApiHandler.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/SkyWalkingTraceQLApiHandler.java
@@ -35,10 +35,9 @@ import org.apache.commons.codec.DecoderException;
 import org.apache.skywalking.oap.query.traceql.TraceQLConfig;
 import org.apache.skywalking.oap.query.traceql.converter.OTLPConverter;
 import org.apache.skywalking.oap.query.traceql.converter.SkyWalkingOTLPConverter;
+import org.apache.skywalking.oap.query.traceql.converter.SkyWalkingSpanMatcher;
 import org.apache.skywalking.oap.query.traceql.entity.OtlpTraceResponse;
 import org.apache.skywalking.oap.query.traceql.entity.SearchResponse;
-import org.apache.skywalking.oap.query.traceql.entity.TagNamesResponse;
-import org.apache.skywalking.oap.query.traceql.entity.TagNamesV2Response;
 import org.apache.skywalking.oap.query.traceql.entity.TagValuesResponse;
 import org.apache.skywalking.oap.query.traceql.exception.IllegalExpressionException;
 import org.apache.skywalking.oap.query.traceql.rt.TraceQLParseResult;
@@ -46,6 +45,7 @@ import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParams;
 import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParser;
 import org.apache.skywalking.oap.server.core.Const;
 import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
 import org.apache.skywalking.oap.server.core.analysis.IDManager;
 import org.apache.skywalking.oap.server.core.analysis.Layer;
 import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
@@ -58,6 +58,7 @@ import org.apache.skywalking.oap.server.core.query.input.TraceQueryCondition;
 import org.apache.skywalking.oap.server.core.query.type.Endpoint;
 import org.apache.skywalking.oap.server.core.query.type.Pagination;
 import org.apache.skywalking.oap.server.core.query.type.QueryOrder;
+import org.apache.skywalking.oap.server.core.query.type.Service;
 import org.apache.skywalking.oap.server.core.query.type.ServiceInstance;
 import org.apache.skywalking.oap.server.core.query.type.Trace;
 import org.apache.skywalking.oap.server.core.query.type.TraceState;
@@ -71,11 +72,14 @@ import org.joda.time.DateTimeZone;
  * SkyWalking-native implementation of TraceQL API Handler.
  */
 public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
+    private static final List<String> INTRINSIC_TAG_NAMES = Arrays.asList(
+        NAME, STATUS, DURATION, SPAN_INTRINSIC_PREFIX + NAME, SPAN_INTRINSIC_PREFIX + STATUS, SPAN_INTRINSIC_PREFIX + DURATION);
     private final TraceQueryService traceQueryService;
     private final TagAutoCompleteQueryService tagAutoCompleteQueryService;
     private final MetadataQueryService metadataQueryService;
     private final TraceQLConfig traceQLConfig;
     private final Set<String> allowedTags;
+    private final NamingControl namingControl;
     private static final long START_OF_2020_SEC = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeZone.UTC).getMillis() / 1000;
 
     public SkyWalkingTraceQLApiHandler(ModuleManager moduleManager, TraceQLConfig config) {
@@ -89,6 +93,7 @@ public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
         this.metadataQueryService = moduleManager.find(CoreModule.NAME)
                                                  .provider()
                                                  .getService(MetadataQueryService.class);
+        this.namingControl = moduleManager.find(CoreModule.NAME).provider().getService(NamingControl.class);
         this.traceQLConfig = config;
         final String swTagsConfig = config.getSkywalkingTracesListResultTags();
         final Set<String> allowedTagsInit;
@@ -110,6 +115,7 @@ public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
     protected HttpResponse queryTraceImpl(String traceId,
                                           Optional<Long> start,
                                           Optional<Long> end,
+                                          Optional<Boolean> coldStage,
                                           Optional<String> accept) throws IOException, DecoderException {
         // If start/end are provided use them; otherwise cover the full historical range from 2020-01-01 00:00:00 UTC
         Duration duration = buildDuration(
@@ -144,7 +150,8 @@ public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
                                        Optional<Integer> limit,
                                        Optional<Long> start,
                                        Optional<Long> end,
-                                       Optional<Integer> spss) throws IOException {
+                                       Optional<Integer> spss,
+                                       Optional<Boolean> coldStage) throws IOException {
         // Parse TraceQL query
         TraceQLParseResult parseResult = parseTraceQLQuery(query, tags);
         if (parseResult.hasError()) {
@@ -153,98 +160,155 @@ public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
 
         TraceQLQueryParams queryParams = parseResult.getParams();
 
-        // Build TraceQueryCondition
-        TraceQueryCondition condition = new TraceQueryCondition();
-
-        // Set time range
         Duration duration = buildDuration(start, end, traceQLConfig.getLookback());
+        TraceQueryCondition condition;
+        try {
+            condition = toCondition(queryParams, duration);
+        } catch (IllegalArgumentException e) {
+            return badRequestResponse(e.getMessage());
+        }
+
+        // The plain minDuration/maxDuration parameters, when the TraceQL did not set a range
+        try {
+            if (queryParams.getMinDuration() == null && minDuration.isPresent()) {
+                condition.setMinTraceDuration((int) (parseDuration(minDuration.get()) / 1000));
+            }
+        } catch (IllegalExpressionException e) {
+            return badRequestResponse("Invalid minDuration format: " + e.getMessage());
+        }
+        try {
+            if (queryParams.getMaxDuration() == null && maxDuration.isPresent()) {
+                condition.setMaxTraceDuration((int) (parseDuration(maxDuration.get()) / 1000));
+            }
+        } catch (IllegalExpressionException e) {
+            return badRequestResponse("Invalid maxDuration format: " + e.getMessage());
+        }
+
+        // Set pagination
+        Pagination pagination = new Pagination();
+        pagination.setPageNum(1);
+        pagination.setPageSize(limit.orElse(20));
+        condition.setPaging(pagination);
+
+        // Set query order (default: by start time descending)
+        condition.setQueryOrder(QueryOrder.BY_START_TIME);
+
+        // Query traces using TraceQueryService
+        TraceList traceList = traceQueryService.queryTraces(condition);
+
+        // Convert TraceList to SearchResponse
+        SearchResponse response = SkyWalkingOTLPConverter.convertTraceListToSearchResponse(
+            traceList, allowedTags, new SkyWalkingSpanMatcher(queryParams, namingControl), spansPerSpanSet(spss));
+
+        return successResponse(response);
+    }
+
+    /**
+     * TraceQL onto the trace query condition: service, instance and endpoint by their ids, the duration range in
+     * whole milliseconds, {@code status} as the trace state, and every attribute (including
+     * {@code span.http.status_code}) as a tag.
+     */
+    private static TraceQueryCondition toCondition(TraceQLQueryParams queryParams, Duration duration) {
+        if (StringUtil.isNotBlank(queryParams.getKind())) {
+            throw new IllegalArgumentException(
+                "kind is not supported on the SkyWalking datasource: the span type is not a trace query condition");
+        }
+        if ("unset".equalsIgnoreCase(queryParams.getStatus())) {
+            throw new IllegalArgumentException(
+                "status = unset is not supported on the SkyWalking datasource: a span is either ok or error");
+        }
+        if (StringUtil.isNotBlank(queryParams.getRemoteServiceName()) && !ALL.equals(queryParams.getRemoteServiceName())) {
+            throw new IllegalArgumentException(
+                "resource.remote.service is not supported on the SkyWalking datasource: the trace query has no peer condition");
+        }
+        boolean serviceNamed = StringUtil.isNotBlank(queryParams.getServiceName()) && !ALL.equals(queryParams.getServiceName());
+        if (!serviceNamed && StringUtil.isNotBlank(queryParams.getServiceInstance()) && !ALL.equals(queryParams.getServiceInstance())) {
+            throw new IllegalArgumentException(
+                "resource.instance needs resource.service.name on the SkyWalking datasource: instance ids are scoped by service");
+        }
+        if (!serviceNamed && StringUtil.isNotBlank(queryParams.getSpanName()) && !ALL.equals(queryParams.getSpanName())) {
+            throw new IllegalArgumentException(
+                "name needs resource.service.name on the SkyWalking datasource: endpoint ids are scoped by service");
+        }
+        TraceQueryCondition condition = new TraceQueryCondition();
         condition.setQueryDuration(duration);
 
-        // Set service ID if service name is provided
         if (StringUtil.isNotBlank(queryParams.getServiceName())
             && !queryParams.getServiceName().equals(ALL)) {
             String serviceId = IDManager.ServiceID.buildId(queryParams.getServiceName(), true);
             condition.setServiceId(serviceId);
         }
-
         if (StringUtil.isNotBlank(queryParams.getServiceInstance())
             && StringUtil.isNotBlank(condition.getServiceId())
             && !queryParams.getServiceInstance().equals(ALL)) {
-            String instanceId = IDManager.ServiceInstanceID.buildId(condition.getServiceId(), queryParams.getServiceInstance());
-            condition.setServiceInstanceId(instanceId);
+            condition.setServiceInstanceId(
+                IDManager.ServiceInstanceID.buildId(condition.getServiceId(), queryParams.getServiceInstance()));
         }
-
-        // Set endpoint ID if span name is provided
         if (StringUtil.isNotBlank(queryParams.getSpanName())
             && StringUtil.isNotBlank(condition.getServiceId())
             && !queryParams.getSpanName().equals(ALL)) {
-            // Use IDManager to build endpoint ID
-            condition.setEndpointId(
-                IDManager.EndpointID.buildId(condition.getServiceId(), queryParams.getSpanName())
-            );
+            condition.setEndpointId(IDManager.EndpointID.buildId(condition.getServiceId(), queryParams.getSpanName()));
         }
 
-        // Set duration filters (convert from microseconds to milliseconds)
+        // TraceQL durations reach the handler in microseconds; the condition takes whole milliseconds
         if (queryParams.getMinDuration() != null) {
             condition.setMinTraceDuration((int) (queryParams.getMinDuration() / 1000));
-        } else if (minDuration.isPresent()) {
-            try {
-                long durationMicros = parseDuration(minDuration.get());
-                condition.setMinTraceDuration((int) (durationMicros / 1000));
-            } catch (IllegalExpressionException e) {
-                return badRequestResponse("Invalid minDuration format: " + e.getMessage());
-            }
         }
-
         if (queryParams.getMaxDuration() != null) {
             condition.setMaxTraceDuration((int) (queryParams.getMaxDuration() / 1000));
-        } else if (maxDuration.isPresent()) {
-            try {
-                long durationMicros = parseDuration(maxDuration.get());
-                condition.setMaxTraceDuration((int) (durationMicros / 1000));
-            } catch (IllegalExpressionException e) {
-                return badRequestResponse("Invalid maxDuration format: " + e.getMessage());
-            }
         }
 
-        // Set trace state based on status, default ALL.
         condition.setTraceState(TraceState.ALL);
-        if (StringUtil.isNotBlank(queryParams.getStatus())) {
-            if (ERROR.equalsIgnoreCase(queryParams.getStatus())) {
-                condition.setTraceState(TraceState.ERROR);
-            } else if (OK.equalsIgnoreCase(queryParams.getStatus())) {
-                condition.setTraceState(TraceState.SUCCESS);
-            }
+        if (ERROR.equalsIgnoreCase(queryParams.getStatus())) {
+            condition.setTraceState(TraceState.ERROR);
+        } else if (OK.equalsIgnoreCase(queryParams.getStatus())) {
+            condition.setTraceState(TraceState.SUCCESS);
         }
 
-        // Set tags
-        if (queryParams.getTags() != null && !queryParams.getTags().isEmpty()) {
-            List<Tag> tagList = new ArrayList<>();
-            for (Map.Entry<String, String> entry : queryParams.getTags().entrySet()) {
+        // The visitor keeps span.http.status_code apart; the agents record it as an ordinary tag
+        List<Tag> tagList = new ArrayList<>();
+        if (queryParams.getTags() != null) {
+            for (Map.Entry<String, String> entry : queryParams.flatTags().entrySet()) {
                 Tag tag = new Tag();
                 tag.setKey(entry.getKey());
                 tag.setValue(entry.getValue());
                 tagList.add(tag);
             }
+        }
+        if (StringUtil.isNotBlank(queryParams.getHttpStatusCode())) {
+            Tag tag = new Tag();
+            tag.setKey(HTTP_STATUS_CODE);
+            tag.setValue(queryParams.getHttpStatusCode());
+            tagList.add(tag);
+        }
+        if (!tagList.isEmpty()) {
             condition.setTags(tagList);
         }
+        return condition;
+    }
 
-        // Set pagination
+    /**
+     * Tempo's {@code q} on a tag lookup: the newest {@link #TAG_FILTER_SAMPLE_TRACES} traces matching the filter,
+     * with the matcher that picks their matching spans.
+     */
+    private Sample sample(TraceQLQueryParams params, Duration duration) throws IOException {
+        TraceQueryCondition condition = toCondition(params, duration);
         Pagination pagination = new Pagination();
         pagination.setPageNum(1);
-        pagination.setPageSize(limit.orElse(20));
+        pagination.setPageSize(TAG_FILTER_SAMPLE_TRACES);
         condition.setPaging(pagination);
-
-        // Set query order (default: by start time descending)
         condition.setQueryOrder(QueryOrder.BY_START_TIME);
+        return new Sample(traceQueryService.queryTraces(condition), new SkyWalkingSpanMatcher(params, namingControl));
+    }
 
-        // Query traces using TraceQueryService
-        TraceList traceList = traceQueryService.queryTraces(condition);
-
-        // Convert TraceList to SearchResponse
-        SearchResponse response = SkyWalkingOTLPConverter.convertTraceListToSearchResponse(traceList, allowedTags);
+    private static final class Sample {
+        private final TraceList traces;
+        private final SkyWalkingSpanMatcher matcher;
 
-        return successResponse(response);
+        private Sample(TraceList traces, SkyWalkingSpanMatcher matcher) {
+            this.traces = traces;
+            this.matcher = matcher;
+        }
     }
 
     @Override
@@ -252,14 +316,8 @@ public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
                                           Optional<Integer> limit,
                                           Optional<Long> start,
                                           Optional<Long> end) throws IOException {
-        // Get all tag keys for TRACE type (SkyWalking native traces)
         Duration duration = buildDuration(start, end, traceQLConfig.getLookback());
-        Set<String> tagKeys = tagAutoCompleteQueryService.queryTagAutocompleteKeys(TagType.TRACE, duration);
-
-        TagNamesResponse response = new TagNamesResponse();
-        response.setTagNames(new ArrayList<>(tagKeys));
-
-        return successResponse(response);
+        return tagNames(scope, limit, requested -> namesOf(requested, duration));
     }
 
     @Override
@@ -268,23 +326,38 @@ public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
                                             Optional<Integer> limit,
                                             Optional<Long> start,
                                             Optional<Long> end) throws IOException {
-        // Get all tag keys for TRACE type (SkyWalking native traces)
         Duration duration = buildDuration(start, end, traceQLConfig.getLookback());
-        Set<String> tagKeys = tagAutoCompleteQueryService.queryTagAutocompleteKeys(TagType.TRACE, duration);
-
-        TagNamesV2Response response = new TagNamesV2Response();
-        TagNamesV2Response.Scope spanScope = new TagNamesV2Response.Scope();
-        spanScope.setName(SCOPE_SPAN);
-        spanScope.setTags(new ArrayList<>(tagKeys));
-        //for Grafana variables, tempo only supports label query in variables setting.
-        TagNamesV2Response.Scope resourceScope = new TagNamesV2Response.Scope(SCOPE_RESOURCE);
-        resourceScope.getTags().add(SERVICE);
-        resourceScope.getTags().add(INSTANCE);
-        List<TagNamesV2Response.Scope> scopes = new ArrayList<>();
-        scopes.add(spanScope);
-        response.setScopes(scopes);
-        response.getScopes().add(resourceScope);
-        return successResponse(response);
+        TraceQLParseResult filter = parseFilter(q);
+        if (filter != null && filter.hasError()) {
+            return badRequestResponse(filter.getErrorInfo());
+        }
+        if (filter == null || !hasFilter(filter.getParams())) {
+            return tagNamesV2(scope, limit, requested -> namesOf(requested, duration));
+        }
+        try {
+            Sample sample = sample(filter.getParams(), duration);
+            Set<String> searchableKeys = tagAutoCompleteQueryService.queryTagAutocompleteKeys(TagType.TRACE, duration);
+            return tagNamesV2(scope, limit, requested -> SCOPE_INTRINSIC.equals(requested)
+                ? INTRINSIC_TAG_NAMES : SkyWalkingOTLPConverter.tagNames(sample.traces, sample.matcher, requested, searchableKeys));
+        } catch (IllegalArgumentException e) {
+            return badRequestResponse(e.getMessage());
+        }
+    }
+
+    /**
+     * SkyWalking spans carry no kind the trace query can filter on, so the intrinsic scope lists name, status and
+     * duration.
+     */
+    private List<String> namesOf(String scope, Duration duration) throws IOException {
+        switch (scope) {
+            case SCOPE_RESOURCE:
+                //for Grafana variables, tempo only supports label query in variables setting.
+                return Arrays.asList(SERVICE, INSTANCE);
+            case SCOPE_SPAN:
+                return new ArrayList<>(tagAutoCompleteQueryService.queryTagAutocompleteKeys(TagType.TRACE, duration));
+            default:
+                return INTRINSIC_TAG_NAMES;
+        }
     }
 
     @Override
@@ -294,86 +367,67 @@ public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
                                                Optional<Long> start,
                                                Optional<Long> end) throws IOException {
         Duration duration = buildDuration(start, end, traceQLConfig.getLookback());
-
-        // Handle special tags: resource.service.name or resource.service
-        if (tagName.equals(RESOURCE_SERVICE_NAME) || tagName.equals(RESOURCE_SERVICE)) {
-            // Query service names from MetadataQueryService with Layer.GENERAL filter
-            // Only GENERAL layer services use SkyWalking native protocol
-            List<org.apache.skywalking.oap.server.core.query.type.Service> services =
-                metadataQueryService.listServices(Layer.GENERAL.name(), null);
-
-            TagValuesResponse response = new TagValuesResponse();
-            for (org.apache.skywalking.oap.server.core.query.type.Service service : services) {
-                response.getTagValues().add(new TagValuesResponse.TagValue(TYPE_STRING, service.getName()));
+        String tag = normalizeTagName(tagName);
+        TraceQLParseResult filter = parseFilter(query);
+        if (filter != null && filter.hasError()) {
+            return badRequestResponse(filter.getErrorInfo());
+        }
+        // Anything narrower than a service name is answered from a sample of matching traces; the service-scoped
+        // catalogs below are exact and complete, so they keep answering the common service-only case.
+        if (filter != null && hasFilter(filter.getParams()) && !isEnumIntrinsic(tag)
+            && !(onlyServiceName(filter.getParams()) && (NAME.equals(tag) || RESOURCE_INSTANCE.equals(tag)))) {
+            try {
+                Sample sample = sample(filter.getParams(), duration);
+                return successResponse(stringValues(SkyWalkingOTLPConverter.tagValues(sample.traces, sample.matcher, tag), limit));
+            } catch (IllegalArgumentException e) {
+                return badRequestResponse(e.getMessage());
             }
-            return successResponse(response);
         }
 
-        // Handle status tag
-        if (tagName.equals(STATUS)) {
-            TagValuesResponse response = new TagValuesResponse();
-            response.getTagValues().add(new TagValuesResponse.TagValue(TYPE_STRING, OK));
-            response.getTagValues().add(new TagValuesResponse.TagValue(TYPE_STRING, ERROR));
-            return successResponse(response);
+        if (tag.equals(RESOURCE_SERVICE_NAME) || tag.equals(RESOURCE_SERVICE)) {
+            // Only GENERAL layer services use SkyWalking native protocol
+            List<Service> services = metadataQueryService.listServices(Layer.GENERAL.name(), null);
+            return successResponse(stringValues(
+                services.stream().map(Service::getName).collect(Collectors.toList()), limit));
         }
-
-        // Handle span.* prefix tags
-        if (tagName.startsWith(SPAN_PREFIX)) {
-            String actualTagName = tagName.substring(SPAN_PREFIX.length());
-            TagValuesResponse response = new TagValuesResponse();
-
-            Set<String> tagValues = tagAutoCompleteQueryService.queryTagAutocompleteValues(
-                TagType.TRACE,
-                actualTagName,
-                duration
-            );
-
-            for (String value : tagValues) {
-                response.getTagValues().add(new TagValuesResponse.TagValue(TYPE_STRING, value));
-            }
-            return successResponse(response);
+        if (tag.equals(STATUS)) {
+            return successResponse(stringValues(Arrays.asList(OK, ERROR), limit));
         }
-
-        // Handle 'name' tag with TraceQL query filter
-        if (tagName.equals(NAME) || tagName.equals(RESOURCE_INSTANCE)) {
+        if (tag.equals(DURATION)) {
+            // A range intrinsic has no value list, Tempo answers an empty one too.
+            return successResponse(new TagValuesResponse());
+        }
+        if (tag.equals(KIND)) {
+            return badRequestResponse("kind is not supported on the SkyWalking datasource: the span type is not a trace query condition");
+        }
+        if (tag.equals(RESOURCE_REMOTE_SERVICE)) {
+            return badRequestResponse("resource.remote.service is not supported on the SkyWalking datasource: the trace query has no peer condition");
+        }
+        if (tag.startsWith(SPAN_PREFIX)) {
+            return successResponse(stringValues(tagAutoCompleteQueryService.queryTagAutocompleteValues(
+                TagType.TRACE, tag.substring(SPAN_PREFIX.length()), duration), limit));
+        }
+        if (tag.equals(NAME) || tag.equals(RESOURCE_INSTANCE)) {
             if (query.isPresent() && !query.get().isEmpty()) {
                 TraceQLParseResult parseResult = TraceQLQueryParser.extractParams(query.get());
                 if (parseResult.hasError()) {
                     return badRequestResponse(parseResult.getErrorInfo());
                 }
                 TraceQLQueryParams traceQLParams = parseResult.getParams();
-                TagValuesResponse response = new TagValuesResponse();
                 if (StringUtil.isNotBlank(traceQLParams.getServiceName()) && !traceQLParams.getServiceName().equals(ALL)) {
                     String serviceId = IDManager.ServiceID.buildId(traceQLParams.getServiceName(), true);
-                    if (tagName.equals(NAME)) {
-                        List<Endpoint> endpoints = metadataQueryService.findEndpoint(
-                            "",
-                            serviceId,
-                            limit.orElse(100),
-                            duration
-                        );
-
-                        for (Endpoint endpoint : endpoints) {
-                            response.getTagValues()
-                                    .add(new TagValuesResponse.TagValue(TYPE_STRING, endpoint.getName()));
-                        }
-                    } else if (tagName.equals(RESOURCE_INSTANCE)) {
-                        List<ServiceInstance> instances = metadataQueryService.listInstances(
-                            duration,
-                            serviceId
-
-                        );
-
-                        for (ServiceInstance instance : instances) {
-                            response.getTagValues().add(new TagValuesResponse.TagValue(TYPE_STRING, instance.getName()));
-                        }
+                    if (tag.equals(NAME)) {
+                        List<Endpoint> endpoints = metadataQueryService.findEndpoint("", serviceId, limit.orElse(100), duration);
+                        return successResponse(stringValues(
+                            endpoints.stream().map(Endpoint::getName).collect(Collectors.toList()), limit));
                     }
+                    List<ServiceInstance> instances = metadataQueryService.listInstances(duration, serviceId);
+                    return successResponse(stringValues(
+                        instances.stream().map(ServiceInstance::getName).collect(Collectors.toList()), limit));
                 }
-                return successResponse(response);
-            } else {
-                // Return empty list if no query provided, to avoid error as Grafana queries this every time when user enters the query page.
-                return successResponse(new TagValuesResponse());
             }
+            // Empty when no service is named: Grafana asks for these on every visit to the query page.
+            return successResponse(new TagValuesResponse());
         }
         return badRequestResponse("Unsupported tag value query.");
     }
@@ -382,20 +436,18 @@ public class SkyWalkingTraceQLApiHandler extends TraceQLApiHandler {
      * Parse TraceQL query string.
      */
     private TraceQLParseResult parseTraceQLQuery(Optional<String> query, Optional<String> tags) {
-        String traceQLQuery = null;
-
-        // Priority: q parameter > tags parameter
+        // Priority: q parameter > tags parameter; the latter is Tempo's deprecated logfmt form, not TraceQL
         if (query.isPresent() && StringUtil.isNotEmpty(query.get())) {
-            traceQLQuery = query.get();
-        } else if (tags.isPresent() && StringUtil.isNotEmpty(tags.get())) {
-            traceQLQuery = tags.get();
+            return TraceQLQueryParser.extractParams(query.get());
         }
-
-        if (StringUtil.isEmpty(traceQLQuery)) {
-            return TraceQLParseResult.of(new TraceQLQueryParams());
+        if (tags.isPresent() && StringUtil.isNotEmpty(tags.get())) {
+            try {
+                return TraceQLParseResult.of(parseTagsParameter(tags.get()));
+            } catch (IllegalArgumentException e) {
+                return TraceQLParseResult.error(e.getMessage());
+            }
         }
-
-        return TraceQLQueryParser.extractParams(traceQLQuery);
+        return TraceQLParseResult.of(new TraceQLQueryParams());
     }
 
     /**
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/TraceQLApiHandler.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/TraceQLApiHandler.java
index 9ddd2f3447..5ab3787210 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/TraceQLApiHandler.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/TraceQLApiHandler.java
@@ -31,11 +31,26 @@ import com.linecorp.armeria.server.annotation.Param;
 import com.linecorp.armeria.server.annotation.Path;
 import io.grafana.tempo.tempopb.TraceByIDResponse;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
 import java.util.Optional;
+import java.util.function.Function;
 import org.apache.commons.codec.DecoderException;
 import org.apache.skywalking.oap.query.traceql.entity.BuildInfoResponse;
 import org.apache.skywalking.oap.query.traceql.entity.ErrorResponse;
 import org.apache.skywalking.oap.query.traceql.entity.QueryResponse;
+import org.apache.skywalking.oap.query.traceql.entity.TagNamesResponse;
+import org.apache.skywalking.oap.query.traceql.entity.TagNamesV2Response;
+import org.apache.skywalking.oap.query.traceql.entity.TagValuesResponse;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLParseResult;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParams;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParser;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
 import org.apache.skywalking.oap.server.core.query.enumeration.Step;
 import org.apache.skywalking.oap.server.core.query.input.Duration;
 import org.joda.time.DateTime;
@@ -47,6 +62,7 @@ import org.joda.time.DateTime;
  * Error Handling:
  * - 200 OK: Successful request
  * - 400 Bad Request: Invalid request parameters (malformed TraceQL query, invalid duration format, etc.)
+ * - 501 Not Implemented: TraceQL metrics endpoints, which no datasource serves
  */
 public abstract class TraceQLApiHandler {
     private static final ObjectMapper MAPPER = new ObjectMapper();
@@ -57,6 +73,17 @@ public abstract class TraceQLApiHandler {
     // Intrinsic tag names
     public static final String STATUS = "status";
     public static final String NAME = "name";
+    public static final String KIND = "kind";
+    public static final String DURATION = "duration";
+    /**
+     * Tempo's scoped spelling of the span intrinsics, {@code span:kind}; accepted wherever the plain one is.
+     */
+    public static final String SPAN_INTRINSIC_PREFIX = "span:";
+    /**
+     * The tag the visitor stores {@code span.http.status_code} filters under, the pre-1.23 semantic-convention
+     * spelling that the SkyWalking agents and Zipkin instrumentations write; every datasource filters it as a tag.
+     */
+    public static final String HTTP_STATUS_CODE = "http.status_code";
 
     // Resource-scoped tag names
     public static final String RESOURCE_SERVICE_NAME = "resource.service.name";
@@ -67,6 +94,12 @@ public abstract class TraceQLApiHandler {
     public static final String SERVICE = "service";
     public static final String REMOTE_SERVICE = "remote.service";
     public static final String INSTANCE = "instance";
+    public static final String SERVICE_INSTANCE_ID = "service.instance.id";
+    /**
+     * The keys an unscoped {@code .key} tag name resolves to the resource scope; see {@link #normalizeTagName}.
+     */
+    private static final List<String> RESOURCE_TAG_KEYS = Arrays.asList(
+        SERVICE_NAME, SERVICE, REMOTE_SERVICE, INSTANCE, SERVICE_INSTANCE_ID);
 
     // Span-scoped tag prefix and names
     public static final String SPAN_PREFIX = "span.";
@@ -77,6 +110,28 @@ public abstract class TraceQLApiHandler {
     public static final String ERROR = "error";
     public static final String OK = "ok";
     public static final String TYPE_STRING = "string";
+    // Tempo's scopes on the tag endpoints
+    public static final String SCOPE_INTRINSIC = "intrinsic";
+    public static final String SCOPE_EVENT = "event";
+    public static final String SCOPE_LINK = "link";
+    public static final String SCOPE_INSTRUMENTATION = "instrumentation";
+    private static final String METRICS_NOT_SUPPORTED =
+        "TraceQL metrics are not supported: the trace stores answer trace and span queries, not aggregations.";
+    protected static final List<String> ALL_SCOPES = Arrays.asList(SCOPE_RESOURCE, SCOPE_SPAN, SCOPE_INTRINSIC);
+    /**
+     * Scopes Tempo defines but no datasource has names for: events, links and the instrumentation scope are stored
+     * inside the span, not indexed.
+     */
+    protected static final List<String> EMPTY_SCOPES = Arrays.asList(SCOPE_EVENT, SCOPE_LINK, SCOPE_INSTRUMENTATION);
+    /**
+     * Tempo's default for {@code spss}, the spans listed per span set.
+     */
+    private static final int DEFAULT_SPANS_PER_SPAN_SET = 3;
+    /**
+     * The most matching traces a tag lookup filtered by {@code q} samples. Tempo's filtered lookups are samples too,
+     * bounded by bytes inspected rather than traces; the answer feeds a dropdown, not a report.
+     */
+    protected static final int TAG_FILTER_SAMPLE_TRACES = 50;
 
     public static final String ALL = "*";
 
@@ -126,8 +181,9 @@ public abstract class TraceQLApiHandler {
     public HttpResponse queryTraceV1(@Param("traceId") String traceId,
                                      @Param("start") Optional<Long> start,
                                      @Param("end") Optional<Long> end,
+                                     @Param("coldStage") Optional<Boolean> coldStage,
                                      @Header("Accept") Optional<String> accept) throws IOException, DecoderException {
-        return queryTrace(traceId, start, end, accept);
+        return queryTrace(traceId, start, end, coldStage, accept);
     }
 
     /**
@@ -136,6 +192,8 @@ public abstract class TraceQLApiHandler {
      *
      * @param traceId The trace ID, for SkyWalking native traces, the original traceId has to be hex encoding of the UTF-8 bytes
      *                you can get the encoded traceId from traces list query API `/api/search`.
+     * @param coldStage A SkyWalking addition, honoured by the OTLP datasource on BanyanDB: {@code true} reads the
+     *                  cold stage instead of the hot and warm stages, and needs {@code start} and {@code end}.
      * @param accept Accept header for response format
      * @return Trace data in OTLP format
      */
@@ -144,14 +202,19 @@ public abstract class TraceQLApiHandler {
     public HttpResponse queryTrace(@Param("traceId") String traceId,
                                    @Param("start") Optional<Long> start,
                                    @Param("end") Optional<Long> end,
+                                   @Param("coldStage") Optional<Boolean> coldStage,
                                    @Header("Accept") Optional<String> accept) throws IOException, DecoderException {
-        return queryTraceImpl(traceId, start, end, accept);
+        return queryTraceImpl(traceId, start, end, coldStage, accept);
     }
 
     /**
      * Abstract method to be implemented by subclasses for trace query logic.
      */
-    protected abstract HttpResponse queryTraceImpl(String traceId, Optional<Long> start, Optional<Long> end, Optional<String> accept) throws IOException, DecoderException;
+    protected abstract HttpResponse queryTraceImpl(String traceId,
+                                                   Optional<Long> start,
+                                                   Optional<Long> end,
+                                                   Optional<Boolean> coldStage,
+                                                   Optional<String> accept) throws IOException, DecoderException;
 
     /**
      * Search for traces matching the given criteria.
@@ -165,6 +228,8 @@ public abstract class TraceQLApiHandler {
      * @param start       Start of time range (Unix epoch seconds)
      * @param end         End of time range (Unix epoch seconds)
      * @param spss        Spans per span set
+     * @param coldStage   A SkyWalking addition, honoured by the OTLP datasource on BanyanDB: {@code true} searches
+     *                    the cold stage instead of the hot and warm stages
      * @return Search results with matching traces
      */
     @Get
@@ -176,8 +241,9 @@ public abstract class TraceQLApiHandler {
                                @Param("limit") Optional<Integer> limit,
                                @Param("start") Optional<Long> start,
                                @Param("end") Optional<Long> end,
-                               @Param("spss") Optional<Integer> spss) throws IOException {
-        return searchImpl(query, tags, minDuration, maxDuration, limit, start, end, spss);
+                               @Param("spss") Optional<Integer> spss,
+                               @Param("coldStage") Optional<Boolean> coldStage) throws IOException {
+        return searchImpl(query, tags, minDuration, maxDuration, limit, start, end, spss, coldStage);
     }
 
     /**
@@ -191,7 +257,8 @@ public abstract class TraceQLApiHandler {
         Optional<Integer> limit,
         Optional<Long> start,
         Optional<Long> end,
-        Optional<Integer> spss) throws IOException;
+        Optional<Integer> spss,
+        Optional<Boolean> coldStage) throws IOException;
 
     /**
      * Get all discovered tag names (v1).
@@ -301,39 +368,53 @@ public abstract class TraceQLApiHandler {
     }
 
     /**
-     * Query metrics over a time range.
-     * GET /api/metrics/query_range
+     * TraceQL metrics over a time range, GET /api/metrics/query_range. No datasource supports it: a metrics query
+     * aggregates every span of the window at query time, which the trace stores do not offer, so the answer is 501
+     * with an error body rather than a 200 Grafana cannot parse.
      *
-     * @param query PromQL-like query string
-     * @param start Start timestamp
-     * @param end   End timestamp
-     * @param step  Query resolution step width
-     * @return Metrics data over time range
+     * @param q         Tempo's name for the TraceQL metrics query
+     * @param query     the spelling this handler accepted before Tempo's, kept for callers that used it
+     * @param start     start of the range in unix seconds
+     * @param end       end of the range in unix seconds
+     * @param since     Tempo's relative range, a duration
+     * @param step      the resolution step, a duration
+     * @param exemplars the number of exemplars asked for
+     * @return 501 with an {@link ErrorResponse}
      */
     @Get
     @Path("/api/metrics/query_range")
     public HttpResponse metricsQueryRange(
-        @Param("query") String query,
-        @Param("start") Long start,
-        @Param("end") Long end,
-        @Param("step") Optional<String> step) throws IOException {
-        return HttpResponse.ofJson("Not supported.");
+        @Param("q") Optional<String> q,
+        @Param("query") Optional<String> query,
+        @Param("start") Optional<Long> start,
+        @Param("end") Optional<Long> end,
+        @Param("since") Optional<String> since,
+        @Param("step") Optional<String> step,
+        @Param("exemplars") Optional<Integer> exemplars) throws IOException {
+        return errorResponse(HttpStatus.NOT_IMPLEMENTED, METRICS_NOT_SUPPORTED);
     }
 
     /**
-     * Execute an instant metrics query.
-     * GET /api/metrics/query
+     * Instant TraceQL metrics, GET /api/metrics/query. Not supported, see {@link #metricsQueryRange}.
      *
-     * @param query PromQL-like query string
-     * @param time  Evaluation timestamp
-     * @return Instant metrics data
+     * @param q     Tempo's name for the TraceQL metrics query
+     * @param query the spelling this handler accepted before Tempo's, kept for callers that used it
+     * @param start start of the range in unix seconds
+     * @param end   end of the range in unix seconds
+     * @param since Tempo's relative range, a duration
+     * @param time  the evaluation time this handler accepted before
+     * @return 501 with an {@link ErrorResponse}
      */
     @Get
     @Path("/api/metrics/query")
     public HttpResponse metricsQuery(
-        @Param("query") String query,
+        @Param("q") Optional<String> q,
+        @Param("query") Optional<String> query,
+        @Param("start") Optional<Long> start,
+        @Param("end") Optional<Long> end,
+        @Param("since") Optional<String> since,
         @Param("time") Optional<Long> time) throws IOException {
-        return HttpResponse.ofJson("Not supported.");
+        return errorResponse(HttpStatus.NOT_IMPLEMENTED, METRICS_NOT_SUPPORTED);
     }
 
     /**
@@ -346,6 +427,231 @@ public abstract class TraceQLApiHandler {
         Optional<Long> start,
         Optional<Long> end) throws IOException;
 
+    /**
+     * The tag names a datasource has for one scope, {@link #SCOPE_RESOURCE}, {@link #SCOPE_SPAN} or
+     * {@link #SCOPE_INTRINSIC}.
+     */
+    @FunctionalInterface
+    protected interface TagNamesSource {
+        List<String> namesOf(String scope) throws IOException;
+    }
+
+    /**
+     * Tempo's v1 tag-name list: the names of every requested scope in one flat list, capped by {@code limit}.
+     */
+    protected HttpResponse tagNames(final Optional<String> scope,
+                                    final Optional<Integer> limit,
+                                    final TagNamesSource source) throws IOException {
+        final List<String> names = new ArrayList<>();
+        try {
+            for (final String requested : requestedScopes(scope)) {
+                names.addAll(source.namesOf(requested));
+            }
+        } catch (IllegalArgumentException e) {
+            return badRequestResponse(e.getMessage());
+        }
+        final TagNamesResponse response = new TagNamesResponse();
+        response.getTagNames().addAll(truncate(names, limit));
+        return successResponse(response);
+    }
+
+    /**
+     * Tempo's v2 tag-name list: one entry per requested scope, {@code limit} applied to each scope on its own, as
+     * Tempo documents it.
+     */
+    protected HttpResponse tagNamesV2(final Optional<String> scope,
+                                      final Optional<Integer> limit,
+                                      final TagNamesSource source) throws IOException {
+        final TagNamesV2Response response = new TagNamesV2Response();
+        try {
+            for (final String requested : requestedScopes(scope)) {
+                final TagNamesV2Response.Scope entry = new TagNamesV2Response.Scope(requested);
+                entry.getTags().addAll(truncate(source.namesOf(requested), limit));
+                response.getScopes().add(entry);
+            }
+        } catch (IllegalArgumentException e) {
+            return badRequestResponse(e.getMessage());
+        }
+        return successResponse(response);
+    }
+
+    /**
+     * The scopes a tag-name request covers: all three when {@code scope} is absent, the one asked for otherwise.
+     * Tempo's event, link and instrumentation scopes are valid requests with no names here.
+     */
+    protected static List<String> requestedScopes(final Optional<String> scope) {
+        if (!scope.isPresent() || scope.get().isEmpty()) {
+            return ALL_SCOPES;
+        }
+        final String requested = scope.get().toLowerCase(Locale.ROOT);
+        if (ALL_SCOPES.contains(requested)) {
+            return Collections.singletonList(requested);
+        }
+        if (EMPTY_SCOPES.contains(requested)) {
+            return Collections.emptyList();
+        }
+        throw new IllegalArgumentException("Unsupported scope: " + scope.get() + ", expected one of "
+                                               + ALL_SCOPES + " or " + EMPTY_SCOPES + ".");
+    }
+
+    protected static <T> List<T> truncate(final Collection<T> values, final Optional<Integer> limit) {
+        final int max = limit.filter(value -> value > 0).orElse(Integer.MAX_VALUE);
+        final List<T> list = values instanceof List ? (List<T>) values : new ArrayList<>(values);
+        return list.size() > max ? list.subList(0, max) : list;
+    }
+
+    protected static TagValuesResponse stringValues(final Collection<String> values, final Optional<Integer> limit) {
+        final TagValuesResponse response = new TagValuesResponse();
+        for (final String value : truncate(values, limit)) {
+            response.getTagValues().add(new TagValuesResponse.TagValue(TYPE_STRING, value));
+        }
+        return response;
+    }
+
+    /**
+     * The parsed {@code q} of a tag lookup: null when the parameter is absent or blank, a result carrying an error
+     * when the TraceQL does not parse.
+     */
+    protected static TraceQLParseResult parseFilter(final Optional<String> q) {
+        if (!q.isPresent() || StringUtil.isBlank(q.get())) {
+            return null;
+        }
+        return TraceQLQueryParser.extractParams(q.get());
+    }
+
+    /**
+     * True when the filter carries a condition. Grafana sends {@code {}} when nothing is selected yet, which asks
+     * for the unfiltered catalogs.
+     */
+    protected static boolean hasFilter(final TraceQLQueryParams params) {
+        return isSet(params.getServiceName()) || isSet(params.getRemoteServiceName()) || isSet(params.getServiceInstance())
+            || isSet(params.getSpanName()) || params.getMinDuration() != null || params.getMaxDuration() != null
+            || StringUtil.isNotBlank(params.getStatus()) || StringUtil.isNotBlank(params.getKind())
+            || StringUtil.isNotBlank(params.getHttpStatusCode())
+            || (params.getTags() != null && !params.getTags().isEmpty());
+    }
+
+    /**
+     * True when the service name is the only condition, which the per-service catalogs answer exactly and in full;
+     * a sample is only worth taking for anything narrower.
+     */
+    protected static boolean onlyServiceName(final TraceQLQueryParams params) {
+        return isSet(params.getServiceName())
+            && !isSet(params.getRemoteServiceName()) && !isSet(params.getServiceInstance()) && !isSet(params.getSpanName())
+            && params.getMinDuration() == null && params.getMaxDuration() == null
+            && StringUtil.isBlank(params.getStatus()) && StringUtil.isBlank(params.getKind())
+            && StringUtil.isBlank(params.getHttpStatusCode())
+            && (params.getTags() == null || params.getTags().isEmpty());
+    }
+
+    private static boolean isSet(final String value) {
+        return StringUtil.isNotBlank(value) && !ALL.equals(value);
+    }
+
+    /**
+     * The distinct non-empty values of one span property over a sample, in first-seen order.
+     */
+    protected static <S> List<String> distinct(final Collection<S> spans, final Function<S, String> value) {
+        final LinkedHashSet<String> values = new LinkedHashSet<>();
+        for (final S span : spans) {
+            final String rendered = value.apply(span);
+            if (StringUtil.isNotEmpty(rendered)) {
+                values.add(rendered);
+            }
+        }
+        return new ArrayList<>(values);
+    }
+
+    /**
+     * Tempo's deprecated {@code tags} search parameter, logfmt {@code key=value} pairs separated by spaces, as the
+     * same {@link TraceQLQueryParams} a TraceQL query produces, so every datasource applies its own mapping and
+     * refusals to both forms. {@code service.name} and {@code span.name} (or {@code name}) name the service and the
+     * span; every other key, with a {@code span.} or {@code resource.} scope stripped, is an attribute equality.
+     *
+     * @throws IllegalArgumentException for an entry that is not {@code key=value}
+     */
+    protected static TraceQLQueryParams parseTagsParameter(final String tags) {
+        final TraceQLQueryParams params = new TraceQLQueryParams();
+        for (final String pair : splitLogfmt(tags)) {
+            final int equals = pair.indexOf('=');
+            if (equals <= 0 || equals == pair.length() - 1) {
+                throw new IllegalArgumentException("Invalid tags parameter: expected key=value pairs, got " + pair);
+            }
+            final String key = pair.substring(0, equals).trim();
+            String value = pair.substring(equals + 1).trim();
+            if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
+                value = value.substring(1, value.length() - 1);
+            }
+            if (SERVICE_NAME.equals(key) || RESOURCE_SERVICE_NAME.equals(key)) {
+                params.setServiceName(value);
+            } else if (SPAN_NAME.equals(key) || NAME.equals(key)) {
+                params.setSpanName(value);
+            } else {
+                // kept as written, scope included, the way the TraceQL visitor stores attributes
+                params.getTags().put(key, value);
+            }
+        }
+        return params;
+    }
+
+    /**
+     * logfmt pairs are separated by whitespace, except inside a double-quoted value ({@code name="GET /"}).
+     */
+    private static List<String> splitLogfmt(final String tags) {
+        final List<String> pairs = new ArrayList<>();
+        final StringBuilder current = new StringBuilder();
+        boolean quoted = false;
+        for (final char c : tags.toCharArray()) {
+            if (c == '"') {
+                quoted = !quoted;
+                current.append(c);
+            } else if (Character.isWhitespace(c) && !quoted) {
+                if (current.length() > 0) {
+                    pairs.add(current.toString());
+                    current.setLength(0);
+                }
+            } else {
+                current.append(c);
+            }
+        }
+        if (current.length() > 0) {
+            pairs.add(current.toString());
+        }
+        return pairs;
+    }
+
+    /**
+     * {@code status} and {@code kind} are enums: their value lists come from the datasource's fixed set whatever
+     * {@code q} says, because the dropdown is where a user goes to look for failures and a 50-trace sample seeing no
+     * error is no reason to hide the value. Only attributes and span names are narrowed by the sample.
+     */
+    protected static boolean isEnumIntrinsic(final String tag) {
+        return STATUS.equals(tag) || KIND.equals(tag);
+    }
+
+    /**
+     * The spellings Tempo accepts on the tag-value endpoint, folded onto the ones the handlers switch on:
+     * {@code span:status} is the {@code status} intrinsic, and an unscoped {@code .key} looks in both scopes, so it
+     * becomes {@code resource.key} for the keys the resource scope lists and {@code span.key} for everything else.
+     */
+    protected static String normalizeTagName(final String tagName) {
+        if (tagName.startsWith(SPAN_INTRINSIC_PREFIX)) {
+            return tagName.substring(SPAN_INTRINSIC_PREFIX.length());
+        }
+        if (tagName.startsWith(".") && tagName.length() > 1) {
+            final String key = tagName.substring(1);
+            return (RESOURCE_TAG_KEYS.contains(key) ? SCOPE_RESOURCE : SCOPE_SPAN) + "." + key;
+        }
+        return tagName;
+    }
+
+    /**
+     * Tempo's {@code spss}: the spans a span set lists, default 3; zero or a negative value means every match.
+     */
+    protected static int spansPerSpanSet(final Optional<Integer> spss) {
+        return spss.filter(value -> value > 0).orElse(DEFAULT_SPANS_PER_SPAN_SET);
+    }
+
     /**
      * Create a successful HTTP response with JSON content.
      */
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/ZipkinTraceQLApiHandler.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/ZipkinTraceQLApiHandler.java
index 80817a01e6..6dafd0a052 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/ZipkinTraceQLApiHandler.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/handler/ZipkinTraceQLApiHandler.java
@@ -22,7 +22,9 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.linecorp.armeria.common.HttpResponse;
 import io.grafana.tempo.tempopb.TraceByIDResponse;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -34,10 +36,9 @@ import org.apache.commons.codec.DecoderException;
 import org.apache.skywalking.oap.query.traceql.TraceQLConfig;
 import org.apache.skywalking.oap.query.traceql.converter.OTLPConverter;
 import org.apache.skywalking.oap.query.traceql.converter.ZipkinOTLPConverter;
+import org.apache.skywalking.oap.query.traceql.converter.ZipkinSpanMatcher;
 import org.apache.skywalking.oap.query.traceql.entity.OtlpTraceResponse;
 import org.apache.skywalking.oap.query.traceql.entity.SearchResponse;
-import org.apache.skywalking.oap.query.traceql.entity.TagNamesResponse;
-import org.apache.skywalking.oap.query.traceql.entity.TagNamesV2Response;
 import org.apache.skywalking.oap.query.traceql.entity.TagValuesResponse;
 import org.apache.skywalking.oap.query.traceql.exception.IllegalExpressionException;
 import org.apache.skywalking.oap.query.traceql.rt.TraceQLParseResult;
@@ -60,6 +61,8 @@ import zipkin2.storage.QueryRequest;
 import static org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryVisitor.parseDuration;
 
 public class ZipkinTraceQLApiHandler extends TraceQLApiHandler {
+    private static final List<String> INTRINSIC_TAG_NAMES = Arrays.asList(
+        NAME, STATUS, DURATION, SPAN_INTRINSIC_PREFIX + NAME, SPAN_INTRINSIC_PREFIX + STATUS, SPAN_INTRINSIC_PREFIX + DURATION);
     private final ZipkinQueryService zipkinQueryService;
     private final TagAutoCompleteQueryService tagAutoCompleteQueryService;
     private final TraceQLConfig traceQLConfig;
@@ -92,6 +95,7 @@ public class ZipkinTraceQLApiHandler extends TraceQLApiHandler {
     protected HttpResponse queryTraceImpl(String traceId,
                                           Optional<Long> start,
                                           Optional<Long> end,
+                                          Optional<Boolean> coldStage,
                                           Optional<String> accept) throws IOException, DecoderException {
         List<Span> zipkinTrace = zipkinQueryService.getTraceById(traceId, null);
 
@@ -119,7 +123,8 @@ public class ZipkinTraceQLApiHandler extends TraceQLApiHandler {
                                       Optional<Integer> limit,
                                       Optional<Long> start,
                                       Optional<Long> end,
-                                      Optional<Integer> spss) throws IOException {
+                                      Optional<Integer> spss,
+                                      Optional<Boolean> coldStage) throws IOException {
         try {
             QueryRequest.Builder queryRequestBuilder = QueryRequest.newBuilder();
 
@@ -151,49 +156,17 @@ public class ZipkinTraceQLApiHandler extends TraceQLApiHandler {
                 }
 
                 TraceQLQueryParams traceQLParams = parseResult.getParams();
-
-                // Apply TraceQL parameters
-                if (StringUtil.isNotBlank(traceQLParams.getServiceName())
-                    && !traceQLParams.getServiceName().equals(ALL)) {
-                    queryRequestBuilder.serviceName(traceQLParams.getServiceName());
-                }
-                if (StringUtil.isNotBlank(traceQLParams.getRemoteServiceName())
-                    && !traceQLParams.getRemoteServiceName().equals(ALL)) {
-                    queryRequestBuilder.remoteServiceName(traceQLParams.getRemoteServiceName());
-                }
-                if (StringUtil.isNotBlank(traceQLParams.getSpanName())
-                    && !traceQLParams.getSpanName().equals(ALL)) {
-                    queryRequestBuilder.spanName(traceQLParams.getSpanName());
-                }
-
-                // Use duration from TraceQL
-                if (traceQLParams.getMinDuration() != null) {
-                    queryRequestBuilder.minDuration(traceQLParams.getMinDuration());
-                } else if (minDuration.isPresent()) {
+                applyTraceQL(traceQLParams, queryRequestBuilder);
+                if (traceQLParams.getMinDuration() == null && minDuration.isPresent()) {
                     queryRequestBuilder.minDuration(parseDuration(minDuration.get()));
                 }
-
-                if (traceQLParams.getMaxDuration() != null) {
-                    queryRequestBuilder.maxDuration(traceQLParams.getMaxDuration());
-                } else if (maxDuration.isPresent()) {
+                if (traceQLParams.getMaxDuration() == null && maxDuration.isPresent()) {
                     queryRequestBuilder.maxDuration(parseDuration(maxDuration.get()));
                 }
-
-                Map<String, String> annotationQuery = new HashMap<>();
-                if (CollectionUtils.isNotEmpty(traceQLParams.getTags())) {
-                    annotationQuery.putAll(traceQLParams.getTags());
-                }
-
-                if (StringUtil.isNotBlank(traceQLParams.getStatus())) {
-                    if (ERROR.equalsIgnoreCase(traceQLParams.getStatus())) {
-                        annotationQuery.put(ERROR, "");
-                    }
-                }
-                if (CollectionUtils.isNotEmpty(annotationQuery)) {
-                    queryRequestBuilder.annotationQuery(annotationQuery);
-                }
             } else {
-                parseTagsParameter(tags, queryRequestBuilder);
+                if (tags.isPresent() && !tags.get().isEmpty()) {
+                    applyTraceQL(parseTagsParameter(tags.get()), queryRequestBuilder);
+                }
 
                 if (minDuration.isPresent()) {
                     queryRequestBuilder.minDuration(parseDuration(minDuration.get()));
@@ -207,28 +180,94 @@ public class ZipkinTraceQLApiHandler extends TraceQLApiHandler {
             queryRequestBuilder.limit(limit.orElse(20));
             QueryRequest queryRequest = queryRequestBuilder.build();
             List<List<zipkin2.Span>> traces = zipkinQueryService.getTraces(queryRequest, duration);
-            SearchResponse response = ZipkinOTLPConverter.convertToSearchResponse(traces, allowedTags);
+            SearchResponse response = ZipkinOTLPConverter.convertToSearchResponse(
+                traces, allowedTags, new ZipkinSpanMatcher(queryRequest), spansPerSpanSet(spss));
             return successResponse(response);
         } catch (IllegalExpressionException | IllegalArgumentException e) {
             return badRequestResponse(e.getMessage());
         }
     }
 
+    /**
+     * TraceQL onto the Zipkin query: local and remote service, span name, the duration range, and the annotation
+     * query over tags, where {@code status = error} is Zipkin's {@code error} tag.
+     */
+    private static void applyTraceQL(TraceQLQueryParams traceQLParams, QueryRequest.Builder queryRequestBuilder) {
+        if (StringUtil.isNotBlank(traceQLParams.getKind())) {
+            throw new IllegalArgumentException("kind is not supported on the Zipkin datasource: Zipkin spans carry no filterable kind");
+        }
+        if (StringUtil.isNotBlank(traceQLParams.getServiceInstance()) && !ALL.equals(traceQLParams.getServiceInstance())) {
+            throw new IllegalArgumentException("resource.instance is not supported on the Zipkin datasource: Zipkin spans carry no instance");
+        }
+        if (StringUtil.isNotBlank(traceQLParams.getStatus()) && !ERROR.equalsIgnoreCase(traceQLParams.getStatus())) {
+            throw new IllegalArgumentException(
+                "status = " + traceQLParams.getStatus() + " is not supported on the Zipkin datasource: only status = error, Zipkin's error tag");
+        }
+        if (StringUtil.isNotBlank(traceQLParams.getServiceName())
+            && !traceQLParams.getServiceName().equals(ALL)) {
+            queryRequestBuilder.serviceName(traceQLParams.getServiceName());
+        }
+        if (StringUtil.isNotBlank(traceQLParams.getRemoteServiceName())
+            && !traceQLParams.getRemoteServiceName().equals(ALL)) {
+            queryRequestBuilder.remoteServiceName(traceQLParams.getRemoteServiceName());
+        }
+        if (StringUtil.isNotBlank(traceQLParams.getSpanName())
+            && !traceQLParams.getSpanName().equals(ALL)) {
+            queryRequestBuilder.spanName(traceQLParams.getSpanName());
+        }
+        if (traceQLParams.getMinDuration() != null) {
+            queryRequestBuilder.minDuration(traceQLParams.getMinDuration());
+        }
+        if (traceQLParams.getMaxDuration() != null) {
+            queryRequestBuilder.maxDuration(traceQLParams.getMaxDuration());
+        }
+        Map<String, String> annotationQuery = new HashMap<>();
+        if (CollectionUtils.isNotEmpty(traceQLParams.getTags())) {
+            annotationQuery.putAll(traceQLParams.flatTags());
+        }
+        if (StringUtil.isNotBlank(traceQLParams.getHttpStatusCode())) {
+            annotationQuery.put(HTTP_STATUS_CODE, traceQLParams.getHttpStatusCode());
+        }
+        if (ERROR.equalsIgnoreCase(traceQLParams.getStatus())) {
+            annotationQuery.put(ERROR, "");
+        }
+        if (CollectionUtils.isNotEmpty(annotationQuery)) {
+            queryRequestBuilder.annotationQuery(annotationQuery);
+        }
+    }
+
+    /**
+     * Tempo's {@code q} on a tag lookup: the newest {@link #TAG_FILTER_SAMPLE_TRACES} traces matching the filter,
+     * with the matcher that picks their matching spans.
+     */
+    private Sample sample(TraceQLQueryParams params, Optional<Long> start, Optional<Long> end) throws IOException {
+        long endTsMillis = end.isPresent() ? end.get() * 1000 : System.currentTimeMillis();
+        long lookbackMillis = start.isPresent() ? endTsMillis - start.get() * 1000 : traceQLConfig.getLookback();
+        QueryRequest.Builder builder = QueryRequest.newBuilder().endTs(endTsMillis).lookback(lookbackMillis)
+                                                   .limit(TAG_FILTER_SAMPLE_TRACES);
+        applyTraceQL(params, builder);
+        QueryRequest request = builder.build();
+        Duration duration = buildDuration(start, end, traceQLConfig.getLookback());
+        return new Sample(zipkinQueryService.getTraces(request, duration), new ZipkinSpanMatcher(request));
+    }
+
+    private static final class Sample {
+        private final List<List<Span>> traces;
+        private final ZipkinSpanMatcher matcher;
+
+        private Sample(List<List<Span>> traces, ZipkinSpanMatcher matcher) {
+            this.traces = traces;
+            this.matcher = matcher;
+        }
+    }
+
     @Override
     protected HttpResponse searchTagsImpl(Optional<String> scope,
                                           Optional<Integer> limit,
                                           Optional<Long> start,
                                           Optional<Long> end) throws IOException {
-        TagNamesResponse response = new TagNamesResponse();
         Duration duration = buildDuration(start, end, traceQLConfig.getLookback());
-
-        Set<String> tagKeys = tagAutoCompleteQueryService.queryTagAutocompleteKeys(
-            TagType.ZIPKIN,
-            duration
-        );
-        response.getTagNames().addAll(tagKeys);
-
-        return successResponse(response);
+        return tagNames(scope, limit, requested -> namesOf(requested, duration));
     }
 
     @Override
@@ -237,24 +276,36 @@ public class ZipkinTraceQLApiHandler extends TraceQLApiHandler {
                                             Optional<Integer> limit,
                                             Optional<Long> start,
                                             Optional<Long> end) throws IOException {
-        TagNamesV2Response response = new TagNamesV2Response();
         Duration duration = buildDuration(start, end, traceQLConfig.getLookback());
+        TraceQLParseResult filter = parseFilter(q);
+        if (filter != null && filter.hasError()) {
+            return badRequestResponse(filter.getErrorInfo());
+        }
+        if (filter == null || !hasFilter(filter.getParams())) {
+            return tagNamesV2(scope, limit, requested -> namesOf(requested, duration));
+        }
+        try {
+            Sample sample = sample(filter.getParams(), start, end);
+            return tagNamesV2(scope, limit, requested -> SCOPE_INTRINSIC.equals(requested)
+                ? INTRINSIC_TAG_NAMES : ZipkinOTLPConverter.tagNames(sample.traces, sample.matcher, requested));
+        } catch (IllegalArgumentException e) {
+            return badRequestResponse(e.getMessage());
+        }
+    }
 
-        TagNamesV2Response.Scope spanScope = new TagNamesV2Response.Scope(SCOPE_SPAN);
-        //for Grafana variables, tempo only supports label query in variables setting.
-        TagNamesV2Response.Scope resourceScope = new TagNamesV2Response.Scope(SCOPE_RESOURCE);
-
-        Set<String> tagKeys = tagAutoCompleteQueryService.queryTagAutocompleteKeys(
-            TagType.ZIPKIN,
-            duration
-        );
-        resourceScope.getTags().add(SERVICE);
-        resourceScope.getTags().add(REMOTE_SERVICE);
-        response.getScopes().add(resourceScope);
-        spanScope.getTags().addAll(tagKeys);
-        response.getScopes().add(spanScope);
-
-        return successResponse(response);
+    /**
+     * Zipkin has no span kind and no ok status to filter on, so the intrinsic scope lists name, status and duration.
+     */
+    private List<String> namesOf(String scope, Duration duration) throws IOException {
+        switch (scope) {
+            case SCOPE_RESOURCE:
+                //for Grafana variables, tempo only supports label query in variables setting.
+                return Arrays.asList(SERVICE, REMOTE_SERVICE);
+            case SCOPE_SPAN:
+                return new ArrayList<>(tagAutoCompleteQueryService.queryTagAutocompleteKeys(TagType.ZIPKIN, duration));
+            default:
+                return INTRINSIC_TAG_NAMES;
+        }
     }
 
     @Override
@@ -264,65 +315,56 @@ public class ZipkinTraceQLApiHandler extends TraceQLApiHandler {
                                                Optional<Long> start,
                                                Optional<Long> end) throws IOException {
         Duration duration = buildDuration(start, end, traceQLConfig.getLookback());
-
-        if (tagName.equals(RESOURCE_SERVICE_NAME) || tagName.equals(RESOURCE_SERVICE)) {
-            List<String> serviceNames = zipkinQueryService.getServiceNames();
-            TagValuesResponse serviceNameRsp = new TagValuesResponse();
-            for (String serviceName : serviceNames) {
-                TagValuesResponse.TagValue tagValue = new TagValuesResponse.TagValue(TYPE_STRING, serviceName);
-                serviceNameRsp.getTagValues().add(tagValue);
+        String tag = normalizeTagName(tagName);
+        TraceQLParseResult filter = parseFilter(query);
+        if (filter != null && filter.hasError()) {
+            return badRequestResponse(filter.getErrorInfo());
+        }
+        // Anything narrower than a service name is answered from a sample of matching traces; the service-scoped
+        // catalogs below are exact and complete, so they keep answering the common service-only case.
+        if (filter != null && hasFilter(filter.getParams()) && !isEnumIntrinsic(tag)
+            && !(onlyServiceName(filter.getParams()) && (NAME.equals(tag) || RESOURCE_REMOTE_SERVICE.equals(tag)))) {
+            try {
+                Sample sample = sample(filter.getParams(), start, end);
+                return successResponse(stringValues(ZipkinOTLPConverter.tagValues(sample.traces, sample.matcher, tag), limit));
+            } catch (IllegalArgumentException e) {
+                return badRequestResponse(e.getMessage());
             }
-            return successResponse(serviceNameRsp);
-        } else if (tagName.equals(STATUS)) {
-            TagValuesResponse serviceNameRsp = new TagValuesResponse();
-            // zipkin doesn't have an ok status query
-            TagValuesResponse.TagValue tagValue = new TagValuesResponse.TagValue(TYPE_STRING, ERROR);
-            serviceNameRsp.getTagValues().add(tagValue);
-            return successResponse(serviceNameRsp);
-        } else if (tagName.startsWith(SPAN_PREFIX)) {
-            String actualTagKey = tagName.substring(SPAN_PREFIX.length());
-            TagValuesResponse response = new TagValuesResponse();
-
-            Set<String> tagValues = tagAutoCompleteQueryService.queryTagAutocompleteValues(
-                TagType.ZIPKIN,
-                actualTagKey,
-                duration
-            );
+        }
 
-            for (String value : tagValues) {
-                TagValuesResponse.TagValue tagValue = new TagValuesResponse.TagValue(TYPE_STRING, value);
-                response.getTagValues().add(tagValue);
-            }
-            return successResponse(response);
+        if (tag.equals(RESOURCE_SERVICE_NAME) || tag.equals(RESOURCE_SERVICE)) {
+            return successResponse(stringValues(zipkinQueryService.getServiceNames(), limit));
+        } else if (tag.equals(STATUS)) {
+            // zipkin doesn't have an ok status query
+            return successResponse(stringValues(Collections.singletonList(ERROR), limit));
+        } else if (tag.equals(DURATION)) {
+            // A range intrinsic has no value list, Tempo answers an empty one too.
+            return successResponse(new TagValuesResponse());
+        } else if (tag.equals(KIND)) {
+            return badRequestResponse("kind is not supported on the Zipkin datasource: Zipkin spans carry no filterable kind");
+        } else if (tag.equals(RESOURCE_INSTANCE) || tag.equals(SCOPE_RESOURCE + "." + SERVICE_INSTANCE_ID)) {
+            return badRequestResponse("resource.instance is not supported on the Zipkin datasource: Zipkin spans carry no instance");
+        } else if (tag.startsWith(SPAN_PREFIX)) {
+            return successResponse(stringValues(tagAutoCompleteQueryService.queryTagAutocompleteValues(
+                TagType.ZIPKIN, tag.substring(SPAN_PREFIX.length()), duration), limit));
         }
-        if (tagName.equals(NAME) || tagName.equals(RESOURCE_REMOTE_SERVICE)) {
+        if (tag.equals(NAME) || tag.equals(RESOURCE_REMOTE_SERVICE)) {
             if (query.isPresent() && !query.get().isEmpty()) {
                 TraceQLParseResult parseResult = TraceQLQueryParser.extractParams(query.get());
                 if (parseResult.hasError()) {
                     return badRequestResponse(parseResult.getErrorInfo());
                 }
                 TraceQLQueryParams traceQLParams = parseResult.getParams();
-                TagValuesResponse serviceNameRsp = new TagValuesResponse();
                 if (StringUtil.isNotBlank(traceQLParams.getServiceName()) && !traceQLParams.getServiceName().equals(ALL)) {
-                    if (tagName.equals(NAME)) {
-                        List<String> spanNames = zipkinQueryService.getSpanNames(traceQLParams.getServiceName());
-                        for (String spanName : spanNames) {
-                            TagValuesResponse.TagValue tagValue = new TagValuesResponse.TagValue(TYPE_STRING, spanName);
-                            serviceNameRsp.getTagValues().add(tagValue);
-                        }
-                    } else if (tagName.equals(RESOURCE_REMOTE_SERVICE)) {
-                        List<String> remoteServiceNames = zipkinQueryService.getRemoteServiceNames(traceQLParams.getServiceName());
-                        for (String rs : remoteServiceNames) {
-                            TagValuesResponse.TagValue tagValue = new TagValuesResponse.TagValue(TYPE_STRING, rs);
-                            serviceNameRsp.getTagValues().add(tagValue);
-                        }
+                    if (tag.equals(NAME)) {
+                        return successResponse(stringValues(zipkinQueryService.getSpanNames(traceQLParams.getServiceName()), limit));
                     }
+                    return successResponse(stringValues(
+                        zipkinQueryService.getRemoteServiceNames(traceQLParams.getServiceName()), limit));
                 }
-                return successResponse(serviceNameRsp);
-            } else {
-                // Return empty list if no query provide, to avoid error as Grafana query this every time when user enter the query page.
-                return successResponse(new TagValuesResponse());
             }
+            // Empty when no service is named: Grafana asks for these on every visit to the query page.
+            return successResponse(new TagValuesResponse());
         }
         return badRequestResponse("Unsupported tag value query.");
     }
@@ -335,25 +377,4 @@ public class ZipkinTraceQLApiHandler extends TraceQLApiHandler {
         OtlpTraceResponse jsonResponse = OTLPConverter.convertProtobufToJson(protoResponse, OTLPConverter.TraceType.ZIPKIN);
         return successResponse(jsonResponse);
     }
-
-    /**
-     * Parse tags parameter and apply to QueryRequest.Builder.
-     */
-    private void parseTagsParameter(Optional<String> tags, QueryRequest.Builder queryRequestBuilder) {
-        if (tags.isPresent() && !tags.get().isEmpty()) {
-            String[] tagPairs = tags.get().split(" ");
-            for (String tagPair : tagPairs) {
-                String[] kv = tagPair.split(Const.EQUAL);
-                if (kv.length == 2) {
-                    String key = kv[0].trim();
-                    String value = kv[1].trim();
-                    if (SERVICE_NAME.equals(key)) {
-                        queryRequestBuilder.serviceName(value);
-                    } else if (SPAN_NAME.equals(key)) {
-                        queryRequestBuilder.spanName(value);
-                    }
-                }
-            }
-        }
-    }
 }
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryParams.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryParams.java
index f1b5d385ae..7651e9e29a 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryParams.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryParams.java
@@ -27,6 +27,8 @@ import java.util.Map;
  */
 @Data
 public class TraceQLQueryParams {
+    private static final String[] SCOPE_PREFIXES = {"resource.", "span."};
+
     /**
      * Service name filter
      */
@@ -58,10 +60,30 @@ public class TraceQLQueryParams {
     private Long maxDuration;
 
     /**
-     * Additional tag filters (resource, span, or intrinsic attributes)
+     * Attribute equality filters keyed as written, {@code resource.env}, {@code span.env} or the unscoped
+     * {@code env}. The OTLP datasource matches each in its own scope; the others take {@link #flatTags()}.
      */
     private Map<String, String> tags = new HashMap<>();
 
+    /**
+     * @return the tags with the {@code resource.} and {@code span.} scopes stripped, for the Zipkin and SkyWalking
+     * datasources whose tag indexes have no scopes
+     */
+    public Map<String, String> flatTags() {
+        final Map<String, String> flat = new HashMap<>(tags.size());
+        for (final Map.Entry<String, String> tag : tags.entrySet()) {
+            String key = tag.getKey();
+            for (final String scope : SCOPE_PREFIXES) {
+                if (key.startsWith(scope)) {
+                    key = key.substring(scope.length());
+                    break;
+                }
+            }
+            flat.put(key, tag.getValue());
+        }
+        return flat;
+    }
+
     /**
      * HTTP status code filter
      */
@@ -71,4 +93,10 @@ public class TraceQLQueryParams {
      * Status filter
      */
     private String status;
+
+    /**
+     * Span kind filter, the TraceQL spelling: {@code server}, {@code client}, {@code producer}, {@code consumer},
+     * {@code internal} or {@code unspecified}. Served by the OTLP datasource only.
+     */
+    private String kind;
 }
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryParser.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryParser.java
index 7c6e4f7d82..296e5c248f 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryParser.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryParser.java
@@ -18,9 +18,15 @@
 
 package org.apache.skywalking.oap.query.traceql.rt;
 
+import java.util.ArrayList;
+import java.util.List;
+import org.antlr.v4.runtime.BaseErrorListener;
 import org.antlr.v4.runtime.CharStream;
 import org.antlr.v4.runtime.CharStreams;
 import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.RecognitionException;
+import org.antlr.v4.runtime.Recognizer;
+import org.antlr.v4.runtime.Token;
 import org.antlr.v4.runtime.tree.ParseTree;
 import org.apache.skywalking.oap.query.tempo.grammar.TraceQLLexer;
 import org.apache.skywalking.oap.query.tempo.grammar.TraceQLParser;
@@ -42,7 +48,36 @@ public class TraceQLQueryParser {
         TraceQLLexer lexer = new TraceQLLexer(input);
         CommonTokenStream tokens = new CommonTokenStream(lexer);
         TraceQLParser parser = new TraceQLParser(tokens);
-        return parser.query();
+        // ANTLR's default listener prints the error and recovers, which used to hand the visitor a partial tree and
+        // let a query with an unsupported construct answer with unfiltered traces (#14093). Collect instead and fail.
+        final SyntaxErrors errors = new SyntaxErrors();
+        lexer.removeErrorListeners();
+        lexer.addErrorListener(errors);
+        parser.removeErrorListeners();
+        parser.addErrorListener(errors);
+        final ParseTree tree = parser.query();
+        if (!errors.messages.isEmpty()) {
+            throw new IllegalArgumentException("Invalid TraceQL: " + String.join("; ", errors.messages));
+        }
+        return tree;
+    }
+
+    private static final class SyntaxErrors extends BaseErrorListener {
+        private final List<String> messages = new ArrayList<>();
+
+        @Override
+        public void syntaxError(final Recognizer<?, ?> recognizer,
+                                final Object offendingSymbol,
+                                final int line,
+                                final int charPositionInLine,
+                                final String msg,
+                                final RecognitionException e) {
+            // OR is the one unsupported construct users meet by accident: Grafana's query builder emits
+            // `{(a || b)}` for a multi-select value. Name it instead of echoing the grammar's expectation.
+            final String reason = offendingSymbol instanceof Token && "||".equals(((Token) offendingSymbol).getText())
+                ? "OR (||) is not supported, select one value at a time" : msg;
+            messages.add("line " + line + ":" + charPositionInLine + " " + reason);
+        }
     }
 
     /**
@@ -57,7 +92,7 @@ public class TraceQLQueryParser {
             TraceQLQueryVisitor visitor = new TraceQLQueryVisitor();
             return visitor.visit(tree);
         } catch (Throwable t) {
-            return TraceQLParseResult.error("Failed to parse TraceQL: " + t.getMessage());
+            return TraceQLParseResult.error(t.getMessage());
         }
     }
 }
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryVisitor.java b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryVisitor.java
index f86ad18ddf..ed7b70ccae 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryVisitor.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/main/java/org/apache/skywalking/oap/query/traceql/rt/TraceQLQueryVisitor.java
@@ -18,6 +18,9 @@
 
 package org.apache.skywalking.oap.query.traceql.rt;
 
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
 import org.apache.skywalking.oap.query.tempo.grammar.TraceQLParser;
 import org.apache.skywalking.oap.query.tempo.grammar.TraceQLParserBaseVisitor;
 import org.apache.skywalking.oap.query.traceql.exception.IllegalExpressionException;
@@ -28,6 +31,10 @@ import org.apache.skywalking.oap.server.core.Const;
  */
 public class TraceQLQueryVisitor extends TraceQLParserBaseVisitor<TraceQLParseResult> {
 
+    private static final List<String> STATUS_VALUES = Arrays.asList("error", "ok", "unset");
+    private static final List<String> KIND_VALUES = Arrays.asList(
+        "unspecified", "internal", "server", "client", "producer", "consumer");
+
     private final TraceQLQueryParams params = new TraceQLQueryParams();
 
     @Override
@@ -36,11 +43,19 @@ public class TraceQLQueryVisitor extends TraceQLParserBaseVisitor<TraceQLParseRe
         return TraceQLParseResult.of(params);
     }
 
+    /**
+     * Every construct this visitor does not map is refused with {@link IllegalArgumentException}, which the handlers
+     * answer with 400. Dropping it would return traces the query excludes, see #14093.
+     */
     @Override
     public TraceQLParseResult visitAttributeFilterExpr(TraceQLParser.AttributeFilterExprContext ctx) {
         String attribute = extractAttributeName(ctx.attribute());
         String operator = ctx.operator().getText();
         String value = extractStaticValue(ctx.staticValue());
+        if (!"=".equals(operator)) {
+            throw new IllegalArgumentException(
+                "Unsupported operator " + operator + " on " + attribute + ": attributes support = only");
+        }
 
         // Handle specific attributes
         // Note: unscoped .service.name becomes "service.name", scoped becomes "resource.service.name"
@@ -48,37 +63,26 @@ public class TraceQLQueryVisitor extends TraceQLParserBaseVisitor<TraceQLParseRe
             case "service.name":
             case "resource.service.name":
             case "resource.service":
-                if ("=".equals(operator)) {
-                    params.setServiceName(value);
-                }
+                params.setServiceName(value);
                 break;
             case "resource.remote.service":
-                if ("=".equals(operator)) {
-                    params.setRemoteServiceName(value);
-                }
+                params.setRemoteServiceName(value);
                 break;
             case "resource.instance":
-                if ("=".equals(operator)) {
-                    params.setServiceInstance(value);
-                }
+                params.setServiceInstance(value);
                 break;
             case "span.name":
             case "name":
-                if ("=".equals(operator)) {
-                    params.setSpanName(value);
-                }
+                params.setSpanName(value);
                 break;
             case "http.status_code":
             case "span.http.status_code":
-                if ("=".equals(operator)) {
-                    params.setHttpStatusCode(value);
-                }
+                params.setHttpStatusCode(value);
                 break;
             default:
-                // Store other attributes
-                // Remove scope prefix if present (e.g., span.http.method -> http.method)
-                String tagKey = removeScopePrefix(attribute);
-                params.getTags().put(tagKey, value);
+                // Kept as written: `resource.env`, `span.env` or the unscoped `env`, which the OTLP datasource
+                // matches in its own scope; the Zipkin and SkyWalking datasources read them flattened.
+                params.getTags().put(attribute, value);
                 break;
         }
 
@@ -91,30 +95,92 @@ public class TraceQLQueryVisitor extends TraceQLParserBaseVisitor<TraceQLParseRe
         String operator = ctx.operator().getText();
         String value = extractStaticValue(ctx.staticValue());
 
+        // Tempo spells the span intrinsics both ways, `kind` and `span:kind`. The other scopes (trace:, event:,
+        // link:, instrumentation:) have no column to filter on, so they are rejected rather than ignored.
+        final int colon = field.indexOf(':');
+        if (colon > 0) {
+            final String scope = field.substring(0, colon);
+            if (!"span".equals(scope)) {
+                throw new IllegalArgumentException(
+                    "Unsupported intrinsic " + field + ": only span intrinsics (name, kind, status, duration) can be filtered");
+            }
+            field = field.substring(colon + 1);
+        }
+
         // Handle intrinsic fields
         if ("duration".equals(field)) {
+            if (!(">".equals(operator) || ">=".equals(operator) || "<".equals(operator) || "<=".equals(operator))) {
+                throw new IllegalArgumentException(
+                    "Unsupported operator " + operator + " on duration: use >, >=, < or <=");
+            }
             try {
-                long durationMicros = parseDuration(value);
-                if (">".equals(operator) || ">=".equals(operator)) {
+                final long durationMicros = parseDuration(value);
+                // The bounds are inclusive and the microsecond is the smallest unit TraceQL accepts here, so a strict
+                // comparison moves the bound by one microsecond instead of being silently treated as inclusive.
+                if (">".equals(operator)) {
+                    params.setMinDuration(durationMicros + 1);
+                } else if (">=".equals(operator)) {
                     params.setMinDuration(durationMicros);
-                } else if ("<".equals(operator) || "<=".equals(operator)) {
+                } else if ("<".equals(operator)) {
+                    if (durationMicros == 0) {
+                        throw new IllegalArgumentException("duration < 0 matches no span");
+                    }
+                    params.setMaxDuration(durationMicros - 1);
+                } else {
                     params.setMaxDuration(durationMicros);
                 }
             } catch (IllegalExpressionException e) {
                 throw new IllegalArgumentException(e.getMessage());
             }
-        } else if ("name".equals(field)) {
-            // name is the span name
-            if ("=".equals(operator)) {
-                params.setSpanName(value);
-            }
+            return visitChildren(ctx);
+        }
+        if (!"=".equals(operator)) {
+            throw new IllegalArgumentException(
+                "Unsupported operator " + operator + " on " + field + ": intrinsics support = only");
+        }
+        if ("name".equals(field)) {
+            params.setSpanName(value);
         } else if ("status".equals(field)) {
-            params.setStatus(value);
+            String status = value.toLowerCase(Locale.ROOT);
+            if (!STATUS_VALUES.contains(status)) {
+                throw new IllegalArgumentException("Unsupported status value " + value + ": expected one of " + STATUS_VALUES);
+            }
+            params.setStatus(status);
+        } else if ("kind".equals(field)) {
+            String kind = value.toLowerCase(Locale.ROOT);
+            if (!KIND_VALUES.contains(kind)) {
+                throw new IllegalArgumentException("Unsupported kind value " + value + ": expected one of " + KIND_VALUES);
+            }
+            params.setKind(kind);
+        } else {
+            throw new IllegalArgumentException(
+                "Unsupported intrinsic " + field + ": only name, status, kind and duration can be filtered");
         }
 
         return visitChildren(ctx);
     }
 
+    @Override
+    public TraceQLParseResult visitNotExpr(TraceQLParser.NotExprContext ctx) {
+        throw new IllegalArgumentException("Negation (!) is not supported");
+    }
+
+    @Override
+    public TraceQLParseResult visitAttributeExistsExpr(TraceQLParser.AttributeExistsExprContext ctx) {
+        throw new IllegalArgumentException(
+            "Attribute existence checks are not supported: compare " + extractAttributeName(ctx.attribute()) + " with =");
+    }
+
+    @Override
+    public TraceQLParseResult visitSpansetAndExpr(TraceQLParser.SpansetAndExprContext ctx) {
+        throw new IllegalArgumentException("Multiple spansets are not supported: put every condition in one {...}");
+    }
+
+    @Override
+    public TraceQLParseResult visitSpansetOrExpr(TraceQLParser.SpansetOrExprContext ctx) {
+        throw new IllegalArgumentException("Multiple spansets are not supported: || between {...} has no equivalent here");
+    }
+
     /**
      * Extract attribute name from attribute context.
      */
@@ -205,30 +271,4 @@ public class TraceQLQueryVisitor extends TraceQLParserBaseVisitor<TraceQLParseRe
         }
     }
 
-    /**
-     * Remove scope prefix from attribute name.
-     * Examples:
-     *   span.http.method -> http.method
-     *   resource.service.name -> service.name
-     *   http.method -> http.method (unchanged)
-     *
-     * @param attribute Attribute name with or without scope prefix
-     * @return Attribute name without scope prefix
-     */
-    private String removeScopePrefix(String attribute) {
-        if (attribute == null) {
-            return null;
-        }
-
-        // Known scopes: span, resource, event, link, intrinsic
-        String[] knownScopes = {"span.", "resource.", "event.", "link.", "intrinsic."};
-
-        for (String scope : knownScopes) {
-            if (attribute.startsWith(scope)) {
-                return attribute.substring(scope.length());
-            }
-        }
-
-        return attribute;
-    }
 }
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/tempo/parser/TraceQLQueryParserTest.java b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/tempo/parser/TraceQLQueryParserTest.java
index 5a561ccdef..be1b76b502 100644
--- a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/tempo/parser/TraceQLQueryParserTest.java
+++ b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/tempo/parser/TraceQLQueryParserTest.java
@@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * Test TraceQL parser.
@@ -79,7 +80,7 @@ public class TraceQLQueryParserTest {
         assertFalse(result.hasError(), "Parse should succeed");
         TraceQLQueryParams params = result.getParams();
         assertNotNull(params);
-        assertEquals(100000L, params.getMinDuration()); // 100ms = 100000 microseconds
+        assertEquals(100001L, params.getMinDuration()); // > 100ms: at least 100000 + 1 microseconds
     }
 
     @Test
@@ -90,7 +91,7 @@ public class TraceQLQueryParserTest {
         TraceQLQueryParams params = result.getParams();
         assertNotNull(params);
         assertEquals("myservice", params.getServiceName());
-        assertEquals(1000000L, params.getMinDuration()); // 1s = 1000000 microseconds
+        assertEquals(1000001L, params.getMinDuration()); // > 1s: at least 1000000 + 1 microseconds
         assertEquals("200", params.getHttpStatusCode());
     }
 
@@ -107,13 +108,36 @@ public class TraceQLQueryParserTest {
 
     @Test
     public void testScopedHttpAttributes() {
-        // Test that span.http.method is stored as http.method (scope prefix removed)
+        // the scope is kept, the datasources without scopes read the flattened view
         String query = "{span.http.method=\"POST\"}";
         TraceQLParseResult result = TraceQLQueryParser.extractParams(query);
         assertFalse(result.hasError(), "Parse should succeed");
         TraceQLQueryParams params = result.getParams();
         assertNotNull(params);
-        assertEquals("POST", params.getTags().get("http.method"));
+        assertEquals("POST", params.getTags().get("span.http.method"));
+        assertEquals("POST", params.flatTags().get("http.method"));
+    }
+
+    @Test
+    public void testResourceAndSpanScopesStayApart() {
+        TraceQLParseResult result = TraceQLQueryParser.extractParams("{resource.env=\"prod\" && span.env=\"dev\" && .region=\"eu\"}");
+        assertFalse(result.hasError(), result.getErrorInfo());
+        TraceQLQueryParams params = result.getParams();
+        assertEquals("prod", params.getTags().get("resource.env"));
+        assertEquals("dev", params.getTags().get("span.env"));
+        assertEquals("eu", params.getTags().get("region"));
+        assertEquals(3, params.getTags().size());
+        // flattened, the two scopes collapse onto one key: the last one written wins, which is why the OTLP
+        // datasource reads the scoped map instead
+        assertEquals("eu", params.flatTags().get("region"));
+    }
+
+    @Test
+    public void testIntrinsicScopedAttributeIsASyntaxError() {
+        // `intrinsic` is a scope token of the tag-name endpoints only; as an attribute prefix it never parses
+        TraceQLParseResult result = TraceQLQueryParser.extractParams("{intrinsic.name=\"GET /\"}");
+        assertTrue(result.hasError());
+        assertTrue(result.getErrorInfo().startsWith("Invalid TraceQL"), result.getErrorInfo());
     }
 
     @Test
@@ -153,13 +177,90 @@ public class TraceQLQueryParserTest {
         assertEquals("HTTP GET", params.getSpanName());
 
         // Check duration (both min and max should be set)
-        assertEquals(100000L, params.getMinDuration()); // 100ms in microseconds
-        assertEquals(10000L, params.getMaxDuration()); // 10ms in microseconds
+        assertEquals(100001L, params.getMinDuration()); // > 100ms
+        assertEquals(9999L, params.getMaxDuration()); // < 10ms
 
         // Check status
         assertEquals("ok", params.getStatus());
 
-        // Check http.method tag
-        assertEquals("GET", params.getTags().get("http.method"));
+        // Check http.method tag, scope kept
+        assertEquals("GET", params.getTags().get("span.http.method"));
+    }
+
+    @Test
+    public void testScopedSpanIntrinsics() {
+        TraceQLParseResult result = TraceQLQueryParser.extractParams("{span:kind=server && span:duration>100ms && span:name=\"GET /\"}");
+        assertFalse(result.hasError(), "Parse should succeed: " + result.getErrorInfo());
+        TraceQLQueryParams params = result.getParams();
+        assertEquals("server", params.getKind());
+        assertEquals(100_001L, params.getMinDuration());
+        assertEquals("GET /", params.getSpanName());
+    }
+
+    @Test
+    public void testOtherScopedIntrinsicsAreRejected() {
+        TraceQLParseResult result = TraceQLQueryParser.extractParams("{trace:duration>1s}");
+        assertTrue(result.hasError(), "trace: intrinsics have no column to filter on");
+        assertTrue(result.getErrorInfo().contains("trace:duration"), result.getErrorInfo());
+    }
+
+    @Test
+    public void testKeywordLiteralsForKindAndStatus() {
+        TraceQLParseResult result = TraceQLQueryParser.extractParams("{kind=server && status=error}");
+        assertFalse(result.hasError(), result.getErrorInfo());
+        assertEquals("server", result.getParams().getKind());
+        assertEquals("error", result.getParams().getStatus());
+        result = TraceQLQueryParser.extractParams("{status=\"OK\"}");
+        assertFalse(result.hasError(), result.getErrorInfo());
+        assertEquals("ok", result.getParams().getStatus());
+    }
+
+    @Test
+    public void testSyntaxErrorsAreRefusedInsteadOfRecovered() {
+        // every one of these used to parse "successfully" with the offending part dropped (#14093)
+        String[] queries = {
+            "{(resource.service.name=\"a\" || resource.service.name=\"b\")}",
+            "{resource.service.name=\"a\" || resource.service.name=\"b\"}",
+            "{span.http.method=~\"G.*\"}",
+            "{http.method=\"NOPE\"}",
+            "{service.name=\"skywalking\"}",
+            "{event.name=\"nope\"}",
+            "{ this is not traceql"
+        };
+        for (String query : queries) {
+            TraceQLParseResult result = TraceQLQueryParser.extractParams(query);
+            assertTrue(result.hasError(), query);
+            assertTrue(result.getErrorInfo().startsWith("Invalid TraceQL: line 1:"), result.getErrorInfo());
+        }
+    }
+
+    @Test
+    public void testOrInsideASpansetNamesTheConstruct() {
+        TraceQLParseResult result = TraceQLQueryParser.extractParams(
+            "{(resource.service.name=\"songs\" || resource.service.name=\"rating\")}");
+        assertTrue(result.hasError());
+        assertTrue(result.getErrorInfo().contains("OR (||) is not supported"), result.getErrorInfo());
+    }
+
+    @Test
+    public void testUnsupportedConstructsAreRefused() {
+        assertRefused("{span.http.method!=\"GET\"}", "Unsupported operator !=");
+        assertRefused("{status!=error}", "Unsupported operator !=");
+        assertRefused("{name>\"a\"}", "Unsupported operator >");
+        assertRefused("{duration=99999ms}", "Unsupported operator = on duration");
+        assertRefused("{status=\"STATUS_CODE_ERROR\"}", "Unsupported status value");
+        assertRefused("{kind=SERVERISH}", "Unsupported kind value");
+        assertRefused("{rootName=\"x\"}", "Unsupported intrinsic rootName");
+        assertRefused("{!(span.http.method=\"GET\")}", "Negation");
+        assertRefused("{span.nonexistent.tag}", "existence");
+        assertRefused("{resource.service.name=\"a\"} && {span.http.method=\"NOPE\"}", "Multiple spansets");
+        assertRefused("{resource.service.name=\"a\"} || {resource.service.name=\"b\"}", "Multiple spansets");
+    }
+
+    private static void assertRefused(String query, String reason) {
+        TraceQLParseResult result = TraceQLQueryParser.extractParams(query);
+        assertTrue(result.hasError(), query + " should be refused");
+        assertTrue(result.getErrorInfo().contains(reason), query + " -> " + result.getErrorInfo());
     }
 }
+
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/OTLPSpanMatcherTest.java b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/OTLPSpanMatcherTest.java
new file mode 100644
index 0000000000..3259a75921
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/OTLPSpanMatcherTest.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.InstrumentationScope;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.Span;
+import io.opentelemetry.proto.trace.v1.Status;
+import java.util.Collections;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.config.group.EndpointNameGrouping;
+import org.apache.skywalking.oap.server.core.query.input.OTLPTraceQueryCondition;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class OTLPSpanMatcherTest {
+    private static final Resource RESOURCE = Resource.newBuilder()
+                                                     .addAttributes(string("k8s.deployment.name", "checkout-deploy"))
+                                                     .addAttributes(string("service.instance", "checkout-1"))
+                                                     .build();
+    private static final InstrumentationScope SCOPE = InstrumentationScope.newBuilder().setName("io.opentelemetry.grpc").build();
+    private static final Span SPAN = Span.newBuilder()
+                                         .setName("Charge")
+                                         .setKind(Span.SpanKind.SPAN_KIND_CLIENT)
+                                         .setStatus(Status.newBuilder().setCode(Status.StatusCode.STATUS_CODE_ERROR))
+                                         .setStartTimeUnixNano(1_000_000L)
+                                         .setEndTimeUnixNano(3_000_000L)
+                                         .addAttributes(string("peer.service", "payment"))
+                                         .addAttributes(KeyValue.newBuilder()
+                                                                .setKey("http.response.status_code")
+                                                                .setValue(AnyValue.newBuilder().setIntValue(503)))
+                                         .build();
+
+    @Test
+    void shouldMatchAnEmptyCondition() {
+        assertTrue(new OTLPSpanMatcher(new OTLPTraceQueryCondition()).matches(RESOURCE, SCOPE, SPAN));
+    }
+
+    @Test
+    void shouldResolveServiceAndInstanceLikeTheReceiver() {
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+        condition.setServiceName("checkout-deploy");
+        condition.setServiceInstance("checkout-1");
+        assertTrue(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+
+        condition.setServiceName("frontend");
+        assertFalse(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+    }
+
+    @Test
+    void shouldMatchColumnsAndRanges() {
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+        condition.setScopeName("io.opentelemetry.grpc");
+        condition.setSpanName("Charge");
+        condition.setPeerService("payment");
+        condition.setKind(Span.SpanKind.SPAN_KIND_CLIENT.getNumber());
+        condition.setStatusCode(Status.StatusCode.STATUS_CODE_ERROR.getNumber());
+        condition.setMinDurationNanos(2_000_000L);
+        condition.setMaxDurationNanos(2_000_000L);
+        assertTrue(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+
+        condition.setMinDurationNanos(2_000_001L);
+        assertFalse(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+        condition.setMinDurationNanos(null);
+        condition.setKind(Span.SpanKind.SPAN_KIND_SERVER.getNumber());
+        assertFalse(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+    }
+
+    @Test
+    void shouldCompareTheNamesTheReceiverIndexed() {
+        // service names cut to 5 characters, span names to 4: the receiver indexed "check", "Char" and "payme"
+        final NamingControl namingControl = new NamingControl(5, 5, 4, new EndpointNameGrouping());
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+        condition.setServiceName("check");
+        condition.setSpanName("Char");
+        condition.setPeerService("payme");
+        assertTrue(new OTLPSpanMatcher(condition, namingControl).matches(RESOURCE, SCOPE, SPAN));
+        assertFalse(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+
+        condition.setServiceName("checkout-deploy");
+        assertFalse(new OTLPSpanMatcher(condition, namingControl).matches(RESOURCE, SCOPE, SPAN));
+    }
+
+    @Test
+    void shouldKeepResourceAndSpanScopesApart() {
+        // the same key on both: `env` is prod on the resource and dev on the span
+        final Resource resource = RESOURCE.toBuilder().addAttributes(string("env", "prod")).build();
+        final Span span = SPAN.toBuilder().addAttributes(string("env", "dev")).build();
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+
+        condition.setTags(Collections.singletonList(new Tag("span.env", "prod")));
+        assertFalse(new OTLPSpanMatcher(condition).matches(resource, SCOPE, span));
+        condition.setTags(Collections.singletonList(new Tag("resource.env", "dev")));
+        assertFalse(new OTLPSpanMatcher(condition).matches(resource, SCOPE, span));
+
+        condition.setTags(Collections.singletonList(new Tag("resource.env", "prod")));
+        assertTrue(new OTLPSpanMatcher(condition).matches(resource, SCOPE, span));
+        condition.setTags(Collections.singletonList(new Tag("span.env", "dev")));
+        assertTrue(new OTLPSpanMatcher(condition).matches(resource, SCOPE, span));
+
+        // unscoped: either scope, as TraceQL defines `.env`
+        condition.setTags(Collections.singletonList(new Tag("env", "prod")));
+        assertTrue(new OTLPSpanMatcher(condition).matches(resource, SCOPE, span));
+        condition.setTags(Collections.singletonList(new Tag("env", "dev")));
+        assertTrue(new OTLPSpanMatcher(condition).matches(resource, SCOPE, span));
+        condition.setTags(Collections.singletonList(new Tag("env", "stage")));
+        assertFalse(new OTLPSpanMatcher(condition).matches(resource, SCOPE, span));
+    }
+
+    @Test
+    void shouldMatchTagsAsTheIndexRendersThem() {
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+        condition.setTags(Collections.singletonList(new Tag("http.response.status_code", "503")));
+        assertTrue(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+
+        condition.setTags(Collections.singletonList(new Tag("k8s.deployment.name", "checkout-deploy")));
+        assertTrue(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+
+        condition.setTags(Collections.singletonList(new Tag("http.response.status_code", "200")));
+        assertFalse(new OTLPSpanMatcher(condition).matches(RESOURCE, SCOPE, SPAN));
+    }
+
+    private static KeyValue string(final String key, final String value) {
+        return KeyValue.newBuilder().setKey(key).setValue(AnyValue.newBuilder().setStringValue(value)).build();
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/OTLPTraceAssemblerTest.java b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/OTLPTraceAssemblerTest.java
new file mode 100644
index 0000000000..2aa8880f3b
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/OTLPTraceAssemblerTest.java
@@ -0,0 +1,268 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.protobuf.ByteString;
+import io.grafana.tempo.tempopb.TraceByIDResponse;
+import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.InstrumentationScope;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
+import io.opentelemetry.proto.trace.v1.ScopeSpans;
+import io.opentelemetry.proto.trace.v1.Span;
+import io.opentelemetry.proto.trace.v1.Status;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.skywalking.oap.query.traceql.entity.SearchResponse;
+import org.apache.skywalking.oap.server.core.query.input.OTLPTraceQueryCondition;
+import org.apache.skywalking.oap.server.core.storage.query.proto.Source;
+import org.apache.skywalking.oap.server.core.storage.query.proto.SpanWrapper;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class OTLPTraceAssemblerTest {
+    private static final ByteString TRACE_ID = ByteString.copyFrom(new byte[] {
+        0x0a, (byte) 0xf7, 0x65, 0x19, 0x16, (byte) 0xcd, 0x43, (byte) 0xdd,
+        (byte) 0x84, 0x48, (byte) 0xeb, 0x21, 0x1c, (byte) 0x80, 0x31, (byte) 0x9c
+    });
+    private static final ByteString ROOT_SPAN_ID = ByteString.copyFrom(new byte[] {1, 2, 3, 4, 5, 6, 7, 8});
+    private static final ByteString CHILD_SPAN_ID = ByteString.copyFrom(new byte[] {0x00, (byte) 0xb7, (byte) 0xad, 0x6b, 0x71, 0x69, 0x20, 0x33});
+    private static final Resource FRONTEND = resource("frontend");
+    private static final Resource CHECKOUT = resource("checkout");
+    private static final InstrumentationScope HTTP_SCOPE = InstrumentationScope.newBuilder().setName("io.opentelemetry.http").build();
+    private static final InstrumentationScope GRPC_SCOPE = InstrumentationScope.newBuilder().setName("io.opentelemetry.grpc").build();
+
+    @Test
+    void shouldRegroupByResourceThenScopeAndOrderSpansByStart() {
+        final Span later = span(CHILD_SPAN_ID, ROOT_SPAN_ID, "GET /cart", 300L, 350L).build();
+        final Span earlier = span(ROOT_SPAN_ID, ByteString.EMPTY, "GET /", 100L, 400L).build();
+        final Span other = span(ByteString.copyFrom(new byte[] {9, 9, 9, 9, 9, 9, 9, 9}), ROOT_SPAN_ID, "Checkout", 200L, 250L).build();
+
+        final TraceByIDResponse response = OTLPTraceAssembler.assemble(Arrays.asList(
+            stored(FRONTEND, HTTP_SCOPE, later),
+            stored(CHECKOUT, GRPC_SCOPE, other),
+            stored(FRONTEND, HTTP_SCOPE, earlier)
+        ));
+
+        final List<ResourceSpans> resourceSpans = response.getTrace().getResourceSpansList();
+        assertEquals(2, resourceSpans.size());
+        assertEquals(FRONTEND, resourceSpans.get(0).getResource());
+        assertEquals(1, resourceSpans.get(0).getScopeSpansCount());
+        final ScopeSpans frontendScope = resourceSpans.get(0).getScopeSpans(0);
+        assertEquals(HTTP_SCOPE, frontendScope.getScope());
+        assertEquals(Arrays.asList("GET /", "GET /cart"),
+                     frontendScope.getSpansList().stream().map(Span::getName).collect(Collectors.toList()));
+        assertEquals(CHECKOUT, resourceSpans.get(1).getResource());
+        assertEquals(GRPC_SCOPE, resourceSpans.get(1).getScopeSpans(0).getScope());
+        assertEquals(other, resourceSpans.get(1).getScopeSpans(0).getSpans(0));
+    }
+
+    @Test
+    void shouldDecodeOnlyOTLPWrappers() throws Exception {
+        final ResourceSpans stored = stored(FRONTEND, HTTP_SCOPE, span(ROOT_SPAN_ID, ByteString.EMPTY, "GET /", 1L, 2L).build());
+        final SpanWrapper otlp = SpanWrapper.newBuilder().setSource(Source.OTLP).setSpan(stored.toByteString()).build();
+        final SpanWrapper zipkin = SpanWrapper.newBuilder().setSource(Source.ZIPKIN).setSpan(ByteString.copyFromUtf8("{}")).build();
+
+        assertEquals(Collections.singletonList(stored), OTLPTraceAssembler.decode(Arrays.asList(zipkin, otlp)));
+    }
+
+    @Test
+    void shouldPrintIdsAsHexInJson() throws Exception {
+        final Span span = span(CHILD_SPAN_ID, ROOT_SPAN_ID, "GET /", 1L, 2L)
+            .addLinks(Span.Link.newBuilder().setTraceId(TRACE_ID).setSpanId(ROOT_SPAN_ID))
+            .build();
+        final TraceByIDResponse response = OTLPTraceAssembler.assemble(
+            Collections.singletonList(stored(FRONTEND, HTTP_SCOPE, span)));
+
+        final JsonObject json = JsonParser.parseString(OTLPTraceAssembler.toJson(response)).getAsJsonObject();
+        final JsonObject printed = json.getAsJsonObject("trace")
+                                       .getAsJsonArray("resourceSpans").get(0).getAsJsonObject()
+                                       .getAsJsonArray("scopeSpans").get(0).getAsJsonObject()
+                                       .getAsJsonArray("spans").get(0).getAsJsonObject();
+        assertEquals("0af7651916cd43dd8448eb211c80319c", printed.get("traceId").getAsString());
+        assertEquals("00b7ad6b71692033", printed.get("spanId").getAsString());
+        assertEquals("0102030405060708", printed.get("parentSpanId").getAsString());
+        assertEquals("SPAN_KIND_SERVER", printed.get("kind").getAsString());
+        assertEquals("1", printed.get("startTimeUnixNano").getAsString());
+        final JsonObject link = printed.getAsJsonArray("links").get(0).getAsJsonObject();
+        assertEquals("0af7651916cd43dd8448eb211c80319c", link.get("traceId").getAsString());
+        assertEquals("0102030405060708", link.get("spanId").getAsString());
+    }
+
+    @Test
+    void shouldListTheSameKeysOnEverySpanWithTypedValues() {
+        final Span root = span(ROOT_SPAN_ID, ByteString.EMPTY, "GET /", 1_000_000L, 4_000_000L)
+            .addAttributes(KeyValue.newBuilder().setKey("http.response.status_code").setValue(AnyValue.newBuilder().setIntValue(200)))
+            .addAttributes(KeyValue.newBuilder().setKey("http.route").setValue(AnyValue.newBuilder().setStringValue("/")))
+            .build();
+        final Span child = span(CHILD_SPAN_ID, ROOT_SPAN_ID, "Checkout", 2_000_000L, 3_000_000L)
+            .setKind(Span.SpanKind.SPAN_KIND_CLIENT)
+            .build();
+        final Set<String> allowed = Set.of("http.response.status_code");
+
+        final SearchResponse response = OTLPTraceAssembler.toSearchResponse(Collections.singletonList(Arrays.asList(
+            stored(CHECKOUT, GRPC_SCOPE, child),
+            stored(FRONTEND, HTTP_SCOPE, root)
+        )), allowed, null, 0);
+
+        assertEquals(1, response.getTraces().size());
+        final SearchResponse.Trace trace = response.getTraces().get(0);
+        assertEquals("0af7651916cd43dd8448eb211c80319c", trace.getTraceID());
+        assertEquals("frontend", trace.getRootServiceName());
+        assertEquals("GET /", trace.getRootTraceName());
+        assertEquals("1000000", trace.getStartTimeUnixNano());
+        assertEquals(3, trace.getDurationMs());
+        final List<SearchResponse.Span> spans = trace.getSpanSets().get(0).getSpans();
+        assertEquals(2, spans.size());
+        assertEquals(2, trace.getSpanSets().get(0).getMatched());
+
+        final SearchResponse.Span checkout = spans.get(0);
+        assertEquals("00b7ad6b71692033", checkout.getSpanID());
+        assertEquals("1000000", checkout.getDurationNanos());
+        assertEquals(Arrays.asList("http.response.status_code", "service.name", "span.kind", "status"), keys(checkout));
+        assertEquals("", checkout.getAttributes().get(0).getValue().getStringValue());
+        assertEquals("checkout", checkout.getAttributes().get(1).getValue().getStringValue());
+        assertEquals("SPAN_KIND_CLIENT", checkout.getAttributes().get(2).getValue().getStringValue());
+        assertEquals("unset", checkout.getAttributes().get(3).getValue().getStringValue());
+
+        final SearchResponse.Span frontend = spans.get(1);
+        assertEquals(keys(checkout), keys(frontend));
+        assertEquals("200", frontend.getAttributes().get(0).getValue().getIntValue());
+        assertNull(frontend.getAttributes().get(0).getValue().getStringValue());
+        assertEquals("frontend", frontend.getAttributes().get(1).getValue().getStringValue());
+        assertTrue(keys(frontend).stream().noneMatch("http.route"::equals));
+    }
+
+    @Test
+    void shouldListOnlyMatchedSpansCappedBySpssAndCountServices() {
+        final Span root = span(ROOT_SPAN_ID, ByteString.EMPTY, "GET /", 1_000_000L, 4_000_000L).build();
+        final Span client = span(CHILD_SPAN_ID, ROOT_SPAN_ID, "Checkout", 2_000_000L, 3_000_000L)
+            .setKind(Span.SpanKind.SPAN_KIND_CLIENT)
+            .build();
+        final Span failed = span(ByteString.copyFrom(new byte[] {7, 7, 7, 7, 7, 7, 7, 7}), ROOT_SPAN_ID, "Charge", 2_100_000L, 2_900_000L)
+            .setStatus(Status.newBuilder().setCode(Status.StatusCode.STATUS_CODE_ERROR))
+            .build();
+        final Span other = span(ByteString.copyFrom(new byte[] {8, 8, 8, 8, 8, 8, 8, 8}), ROOT_SPAN_ID, "Reserve", 2_200_000L, 2_800_000L).build();
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+        condition.setServiceName("checkout");
+        condition.setKind(Span.SpanKind.SPAN_KIND_SERVER.getNumber());
+
+        final SearchResponse response = OTLPTraceAssembler.toSearchResponse(Collections.singletonList(Arrays.asList(
+            stored(FRONTEND, HTTP_SCOPE, root),
+            stored(CHECKOUT, GRPC_SCOPE, client),
+            stored(CHECKOUT, GRPC_SCOPE, failed),
+            stored(CHECKOUT, GRPC_SCOPE, other)
+        )), Set.of(), new OTLPSpanMatcher(condition), 1);
+
+        final SearchResponse.Trace trace = response.getTraces().get(0);
+        final SearchResponse.SpanSet spanSet = trace.getSpanSets().get(0);
+        // The frontend span and the checkout client span do not match; the two checkout server spans do, one listed.
+        assertEquals(2, spanSet.getMatched());
+        assertEquals(1, spanSet.getSpans().size());
+        assertEquals("0707070707070707", spanSet.getSpans().get(0).getSpanID());
+        assertEquals("frontend", trace.getRootServiceName());
+        assertEquals(1, trace.getServiceStats().get("frontend").getSpanCount());
+        assertEquals(0, trace.getServiceStats().get("frontend").getErrorCount());
+        assertEquals(3, trace.getServiceStats().get("checkout").getSpanCount());
+        assertEquals(1, trace.getServiceStats().get("checkout").getErrorCount());
+    }
+
+    @Test
+    void shouldLeaveOutATraceNoneOfWhoseSpansMatch() {
+        final Span root = span(ROOT_SPAN_ID, ByteString.EMPTY, "GET /", 1L, 2L).build();
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+        condition.setSpanName("a name no span of the trace carries");
+
+        final SearchResponse response = OTLPTraceAssembler.toSearchResponse(Collections.singletonList(
+            Collections.singletonList(stored(FRONTEND, HTTP_SCOPE, root))), Set.of(), new OTLPSpanMatcher(condition), 3);
+
+        assertTrue(response.getTraces().isEmpty());
+    }
+
+    @Test
+    void shouldListTagNamesAndValuesOfMatchedSpansOnly() {
+        final Span root = span(ROOT_SPAN_ID, ByteString.EMPTY, "GET /", 1L, 4L)
+            .addAttributes(KeyValue.newBuilder().setKey("http.route").setValue(AnyValue.newBuilder().setStringValue("/")))
+            .build();
+        final Span client = span(CHILD_SPAN_ID, ROOT_SPAN_ID, "Charge", 2L, 3L)
+            .setKind(Span.SpanKind.SPAN_KIND_CLIENT)
+            .addAttributes(KeyValue.newBuilder().setKey("peer.service").setValue(AnyValue.newBuilder().setStringValue("payment")))
+            .addAttributes(KeyValue.newBuilder().setKey("rpc.grpc.status_code").setValue(AnyValue.newBuilder().setIntValue(0)))
+            .build();
+        final List<List<ResourceSpans>> traces = Collections.singletonList(Arrays.asList(
+            stored(FRONTEND, HTTP_SCOPE, root), stored(CHECKOUT, GRPC_SCOPE, client)));
+        final OTLPTraceQueryCondition condition = new OTLPTraceQueryCondition();
+        condition.setKind(Span.SpanKind.SPAN_KIND_CLIENT.getNumber());
+        final OTLPSpanMatcher matcher = new OTLPSpanMatcher(condition);
+
+        assertEquals(Arrays.asList("otel.scope.name", "peer.service", "rpc.grpc.status_code"),
+                     OTLPTraceAssembler.tagNames(traces, matcher, "span"));
+        assertEquals(Collections.singletonList("service.name"), OTLPTraceAssembler.tagNames(traces, matcher, "resource"));
+        assertEquals(Collections.singletonList("checkout"), OTLPTraceAssembler.tagValues(traces, matcher, "resource.service.name"));
+        assertEquals(Collections.singletonList("payment"), OTLPTraceAssembler.tagValues(traces, matcher, "resource.remote.service"));
+        assertEquals(Collections.singletonList("Charge"), OTLPTraceAssembler.tagValues(traces, matcher, "name"));
+        assertEquals(Collections.singletonList("client"), OTLPTraceAssembler.tagValues(traces, matcher, "kind"));
+        assertEquals(Collections.singletonList("unset"), OTLPTraceAssembler.tagValues(traces, matcher, "status"));
+        assertEquals(Collections.singletonList("0"), OTLPTraceAssembler.tagValues(traces, matcher, "span.rpc.grpc.status_code"));
+        assertEquals(Collections.singletonList("io.opentelemetry.grpc"), OTLPTraceAssembler.tagValues(traces, matcher, "span.otel.scope.name"));
+        assertTrue(OTLPTraceAssembler.tagValues(traces, matcher, "duration").isEmpty());
+        assertTrue(OTLPTraceAssembler.tagValues(traces, matcher, "span.http.route").isEmpty());
+        // without a matcher every span counts
+        assertEquals(Arrays.asList("frontend", "checkout"), OTLPTraceAssembler.tagValues(traces, null, "resource.service.name"));
+    }
+
+    private static List<String> keys(final SearchResponse.Span span) {
+        return span.getAttributes().stream().map(SearchResponse.Attribute::getKey).collect(Collectors.toList());
+    }
+
+    private static Resource resource(final String serviceName) {
+        return Resource.newBuilder()
+                       .addAttributes(KeyValue.newBuilder()
+                                              .setKey("service.name")
+                                              .setValue(AnyValue.newBuilder().setStringValue(serviceName)))
+                       .build();
+    }
+
+    private static Span.Builder span(final ByteString spanId, final ByteString parentSpanId, final String name,
+                                     final long startNanos, final long endNanos) {
+        return Span.newBuilder()
+                   .setTraceId(TRACE_ID)
+                   .setSpanId(spanId)
+                   .setParentSpanId(parentSpanId)
+                   .setName(name)
+                   .setKind(Span.SpanKind.SPAN_KIND_SERVER)
+                   .setStartTimeUnixNano(startNanos)
+                   .setEndTimeUnixNano(endNanos);
+    }
+
+    private static ResourceSpans stored(final Resource resource, final InstrumentationScope scope, final Span span) {
+        return ResourceSpans.newBuilder()
+                            .setResource(resource)
+                            .addScopeSpans(ScopeSpans.newBuilder().setScope(scope).addSpans(span))
+                            .build();
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingOTLPConverterTest.java b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingOTLPConverterTest.java
new file mode 100644
index 0000000000..3358cab420
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingOTLPConverterTest.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+import org.apache.skywalking.oap.query.traceql.entity.SearchResponse;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParams;
+import org.apache.skywalking.oap.server.core.query.type.KeyValue;
+import org.apache.skywalking.oap.server.core.query.type.Span;
+import org.apache.skywalking.oap.server.core.query.type.trace.v2.TraceList;
+import org.apache.skywalking.oap.server.core.query.type.trace.v2.TraceV2;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class SkyWalkingOTLPConverterTest {
+    private static Span span(final String segmentSpanId, final boolean root, final String service, final String endpoint,
+                             final String type, final long start, final long end, final boolean error, final String... tags) {
+        final Span span = new Span();
+        span.setTraceId("trace-1");
+        span.setSegmentSpanId(segmentSpanId);
+        span.setRoot(root);
+        span.setServiceCode(service);
+        span.setEndpointName(endpoint);
+        span.setType(type);
+        span.setStartTime(start);
+        span.setEndTime(end);
+        span.setError(error);
+        for (int i = 0; i < tags.length; i += 2) {
+            span.getTags().add(new KeyValue(tags[i], tags[i + 1]));
+        }
+        return span;
+    }
+
+    @Test
+    void shouldListOnlyMatchedSpansCappedBySpssAndCountServices() {
+        final TraceV2 trace = new TraceV2();
+        trace.getSpans().add(span("s1-1", true, "frontend", "GET:/", "Entry", 1_000L, 1_400L, false, "http.method", "GET"));
+        trace.getSpans().add(span("s2-1", false, "checkout", "POST:/charge", "Entry", 1_100L, 1_300L, true, "http.method", "POST", "http.status_code", "500"));
+        trace.getSpans().add(span("s2-2", false, "checkout", "POST:/reserve", "Entry", 1_150L, 1_250L, false, "http.method", "POST"));
+        trace.getSpans().add(span("s2-3", false, "checkout", "notify", "Local", 1_200L, 1_210L, false));
+        final TraceList traceList = new TraceList(null);
+        traceList.getTraces().add(trace);
+        final TraceQLQueryParams params = new TraceQLQueryParams();
+        params.setServiceName("checkout");
+        params.getTags().put("http.method", "POST");
+
+        final SearchResponse response = SkyWalkingOTLPConverter.convertTraceListToSearchResponse(
+            traceList, Set.of("http.method"), new SkyWalkingSpanMatcher(params), 1);
+
+        final SearchResponse.Trace result = response.getTraces().get(0);
+        assertEquals("frontend", result.getRootServiceName());
+        assertEquals("GET:/", result.getRootTraceName());
+        final SearchResponse.SpanSet spanSet = result.getSpanSets().get(0);
+        assertEquals(2, spanSet.getMatched());
+        assertEquals(1, spanSet.getSpans().size());
+        assertEquals("s2-1", spanSet.getSpans().get(0).getSpanID());
+        assertEquals("error", spanSet.getSpans().get(0).getAttributes().stream()
+                                     .filter(a -> "status".equals(a.getKey())).findFirst().get().getValue().getStringValue());
+        assertEquals(1, result.getServiceStats().get("frontend").getSpanCount());
+        assertEquals(3, result.getServiceStats().get("checkout").getSpanCount());
+        assertEquals(1, result.getServiceStats().get("checkout").getErrorCount());
+    }
+
+    @Test
+    void shouldListTagNamesAndValuesOfMatchedSpansOnly() {
+        final TraceV2 trace = new TraceV2();
+        trace.getSpans().add(span("s1-1", true, "frontend", "GET:/", "Entry", 1_000L, 1_400L, false, "http.method", "GET", "http.url", "/"));
+        trace.getSpans().add(span("s2-1", false, "checkout", "POST:/charge", "Entry", 1_100L, 1_300L, true, "http.method", "POST", "http.status_code", "500"));
+        final TraceList traceList = new TraceList(null);
+        traceList.getTraces().add(trace);
+        final TraceQLQueryParams params = new TraceQLQueryParams();
+        params.setServiceName("checkout");
+        final SkyWalkingSpanMatcher matcher = new SkyWalkingSpanMatcher(params);
+
+        assertEquals(Arrays.asList("service", "instance"), SkyWalkingOTLPConverter.tagNames(traceList, matcher, "resource", Set.of()));
+        // only searchable keys are query conditions, so only they are offered
+        assertEquals(Collections.singletonList("http.method"),
+                     SkyWalkingOTLPConverter.tagNames(traceList, matcher, "span", Set.of("http.method", "http.url")));
+        assertEquals(Collections.singletonList("checkout"), SkyWalkingOTLPConverter.tagValues(traceList, matcher, "resource.service.name"));
+        assertEquals(Collections.singletonList("POST:/charge"), SkyWalkingOTLPConverter.tagValues(traceList, matcher, "name"));
+        assertEquals(Collections.singletonList("error"), SkyWalkingOTLPConverter.tagValues(traceList, matcher, "status"));
+        assertEquals(Collections.singletonList("500"), SkyWalkingOTLPConverter.tagValues(traceList, matcher, "span.http.status_code"));
+        assertEquals(Arrays.asList("ok", "error"), SkyWalkingOTLPConverter.tagValues(traceList, null, "status"));
+    }
+
+    @Test
+    void shouldListEverySpanWithoutAMatcher() {
+        final TraceV2 trace = new TraceV2();
+        trace.getSpans().add(span("s1-1", true, "frontend", "GET:/", "Entry", 1_000L, 1_400L, false));
+        trace.getSpans().add(span("s1-2", false, "frontend", "redis", "Exit", 1_100L, 1_200L, false));
+        final TraceList traceList = new TraceList(null);
+        traceList.getTraces().add(trace);
+
+        final SearchResponse response = SkyWalkingOTLPConverter.convertTraceListToSearchResponse(traceList, Set.of(), null, 0);
+
+        assertEquals(2, response.getTraces().get(0).getSpanSets().get(0).getSpans().size());
+        assertEquals(2, response.getTraces().get(0).getSpanSets().get(0).getMatched());
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingSpanMatcherTest.java b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingSpanMatcherTest.java
new file mode 100644
index 0000000000..b1e32a8308
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/SkyWalkingSpanMatcherTest.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParams;
+import org.apache.skywalking.oap.server.core.query.type.KeyValue;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.config.group.EndpointNameGrouping;
+import org.apache.skywalking.oap.server.core.query.type.Span;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SkyWalkingSpanMatcherTest {
+    private static Span span() {
+        final Span span = new Span();
+        span.setServiceCode("frontend");
+        span.setServiceInstanceName("frontend-1");
+        span.setEndpointName("GET:/api");
+        span.setStartTime(1_000L);
+        span.setEndTime(1_250L);
+        span.setError(true);
+        span.getTags().add(new KeyValue("http.method", "GET"));
+        span.getTags().add(new KeyValue("http.status_code", "500"));
+        return span;
+    }
+
+    @Test
+    void shouldMatchAnEmptyQuery() {
+        assertTrue(new SkyWalkingSpanMatcher(new TraceQLQueryParams()).matches(span()));
+    }
+
+    @Test
+    void shouldMatchTheConditionTheHandlerBuilds() {
+        final TraceQLQueryParams params = new TraceQLQueryParams();
+        params.setServiceName("frontend");
+        params.setServiceInstance("frontend-1");
+        params.setSpanName("GET:/api");
+        params.setMinDuration(250_000L);   // TraceQL durations reach the handler in microseconds
+        params.setMaxDuration(250_000L);
+        params.setStatus("error");
+        params.getTags().put("http.method", "GET");
+        params.setHttpStatusCode("500");
+        assertTrue(new SkyWalkingSpanMatcher(params).matches(span()));
+
+        params.setStatus("ok");
+        assertFalse(new SkyWalkingSpanMatcher(params).matches(span()));
+        params.setStatus("error");
+        params.setHttpStatusCode("200");
+        assertFalse(new SkyWalkingSpanMatcher(params).matches(span()));
+        params.setHttpStatusCode(null);
+        // the handler truncates microseconds to whole milliseconds, so 250_001 still means 250 ms
+        params.setMinDuration(250_999L);
+        assertTrue(new SkyWalkingSpanMatcher(params).matches(span()));
+        params.setMinDuration(251_000L);
+        assertFalse(new SkyWalkingSpanMatcher(params).matches(span()));
+    }
+
+    @Test
+    void shouldCompareTheEndpointNameTheStorageIndexed() {
+        // endpoint names cut to 4 characters: the segment's endpoint id carries "GET:", the span the raw "GET:/api"
+        final NamingControl namingControl = new NamingControl(70, 70, 4, new EndpointNameGrouping());
+        final TraceQLQueryParams params = new TraceQLQueryParams();
+        params.setSpanName("GET:");
+        assertTrue(new SkyWalkingSpanMatcher(params, namingControl).matches(span()));
+        assertFalse(new SkyWalkingSpanMatcher(params).matches(span()));
+
+        params.setSpanName("GET:/api");
+        assertFalse(new SkyWalkingSpanMatcher(params, namingControl).matches(span()));
+    }
+
+    @Test
+    void shouldTreatStarAsAnyValue() {
+        final TraceQLQueryParams params = new TraceQLQueryParams();
+        params.setServiceName("*");
+        params.setSpanName("*");
+        assertTrue(new SkyWalkingSpanMatcher(params).matches(span()));
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinOTLPConverterTest.java b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinOTLPConverterTest.java
new file mode 100644
index 0000000000..9fdc1c26a6
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinOTLPConverterTest.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.skywalking.oap.query.traceql.entity.SearchResponse;
+import org.junit.jupiter.api.Test;
+import zipkin2.Endpoint;
+import zipkin2.Span;
+import zipkin2.storage.QueryRequest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ZipkinOTLPConverterTest {
+    private static final String TRACE_ID = "0af7651916cd43dd8448eb211c80319c";
+
+    private static Span span(final String id, final String parentId, final String service, final String name,
+                             final long timestamp, final long duration, final String... tags) {
+        final Span.Builder builder = Span.newBuilder()
+                                         .traceId(TRACE_ID)
+                                         .id(id)
+                                         .parentId(parentId)
+                                         .name(name)
+                                         .localEndpoint(Endpoint.newBuilder().serviceName(service).build())
+                                         .timestamp(timestamp)
+                                         .duration(duration);
+        for (int i = 0; i < tags.length; i += 2) {
+            builder.putTag(tags[i], tags[i + 1]);
+        }
+        return builder.build();
+    }
+
+    @Test
+    void shouldListOnlyMatchedSpansCappedBySpssAndCountServices() {
+        final Span root = span("0000000000000001", null, "frontend", "get /", 1_000L, 4_000L, "http.method", "GET");
+        final Span checkout = span("0000000000000002", "0000000000000001", "checkout", "charge", 2_000L, 1_000L, "http.method", "POST");
+        final Span failed = span("0000000000000003", "0000000000000001", "checkout", "reserve", 2_100L, 800L, "http.method", "POST", "error", "500");
+        final Span other = span("0000000000000004", "0000000000000001", "checkout", "notify", 2_200L, 600L, "http.method", "GET");
+        final QueryRequest request = QueryRequest.newBuilder()
+                                                 .endTs(10_000L).lookback(10_000L).limit(20)
+                                                 .serviceName("checkout")
+                                                 .annotationQuery(new HashMap<>(Map.of("http.method", "POST")))
+                                                 .build();
+
+        final SearchResponse response = ZipkinOTLPConverter.convertToSearchResponse(
+            Collections.singletonList(Arrays.asList(root, checkout, failed, other)),
+            Set.of("http.method"), new ZipkinSpanMatcher(request), 1);
+
+        final SearchResponse.Trace trace = response.getTraces().get(0);
+        assertEquals(TRACE_ID, trace.getTraceID());
+        assertEquals("frontend", trace.getRootServiceName());
+        assertEquals("get /", trace.getRootTraceName());
+        final SearchResponse.SpanSet spanSet = trace.getSpanSets().get(0);
+        assertEquals(2, spanSet.getMatched());
+        assertEquals(1, spanSet.getSpans().size());
+        assertEquals("0000000000000002", spanSet.getSpans().get(0).getSpanID());
+        assertEquals(Arrays.asList("http.method", "service.name", "span.kind", "status"),
+                     spanSet.getSpans().get(0).getAttributes().stream().map(SearchResponse.Attribute::getKey)
+                            .collect(Collectors.toList()));
+        // the listed checkout span has no error tag; the failed one would read "error"
+        assertEquals("unset", spanSet.getSpans().get(0).getAttributes().get(3).getValue().getStringValue());
+        assertEquals(1, trace.getServiceStats().get("frontend").getSpanCount());
+        assertEquals(3, trace.getServiceStats().get("checkout").getSpanCount());
+        assertEquals(1, trace.getServiceStats().get("checkout").getErrorCount());
+    }
+
+    @Test
+    void shouldListTagNamesAndValuesOfMatchedSpansOnly() {
+        final Span root = span("0000000000000001", null, "frontend", "get /", 1_000L, 4_000L, "http.method", "GET", "http.path", "/");
+        final Span failed = span("0000000000000002", "0000000000000001", "checkout", "charge", 2_000L, 1_000L, "http.method", "POST", "error", "500");
+        final List<List<Span>> traces = Collections.singletonList(Arrays.asList(root, failed));
+        final ZipkinSpanMatcher matcher = new ZipkinSpanMatcher(
+            QueryRequest.newBuilder().endTs(10_000L).lookback(10_000L).limit(20).serviceName("checkout").build());
+
+        assertEquals(Arrays.asList("service", "remote.service"), ZipkinOTLPConverter.tagNames(traces, matcher, "resource"));
+        assertEquals(Arrays.asList("error", "http.method"), ZipkinOTLPConverter.tagNames(traces, matcher, "span"));
+        assertEquals(Collections.singletonList("checkout"), ZipkinOTLPConverter.tagValues(traces, matcher, "resource.service.name"));
+        assertEquals(Collections.singletonList("charge"), ZipkinOTLPConverter.tagValues(traces, matcher, "name"));
+        assertEquals(Collections.singletonList("error"), ZipkinOTLPConverter.tagValues(traces, matcher, "status"));
+        assertEquals(Collections.singletonList("POST"), ZipkinOTLPConverter.tagValues(traces, matcher, "span.http.method"));
+        assertEquals(Arrays.asList("GET", "POST"), ZipkinOTLPConverter.tagValues(traces, null, "span.http.method"));
+        assertEquals(Collections.emptyList(), ZipkinOTLPConverter.tagValues(traces, null, "duration"));
+    }
+
+    @Test
+    void shouldListEverySpanWithoutAMatcher() {
+        final Span root = span("0000000000000001", null, "frontend", "get /", 1_000L, 4_000L);
+        final Span child = span("0000000000000002", "0000000000000001", "checkout", "charge", 2_000L, 1_000L);
+
+        final SearchResponse response = ZipkinOTLPConverter.convertToSearchResponse(
+            Collections.singletonList(Arrays.asList(child, root)), Set.of(), null, 0);
+
+        final SearchResponse.SpanSet spanSet = response.getTraces().get(0).getSpanSets().get(0);
+        assertEquals(2, spanSet.getMatched());
+        assertEquals(2, spanSet.getSpans().size());
+        assertEquals("frontend", response.getTraces().get(0).getRootServiceName());
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinSpanMatcherTest.java b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinSpanMatcherTest.java
new file mode 100644
index 0000000000..648bfa9ac5
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/converter/ZipkinSpanMatcherTest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.query.traceql.converter;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import zipkin2.Endpoint;
+import zipkin2.Span;
+import zipkin2.storage.QueryRequest;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ZipkinSpanMatcherTest {
+    private static final Span SPAN = Span.newBuilder()
+                                         .traceId("0af7651916cd43dd8448eb211c80319c")
+                                         .id("00b7ad6b71692033")
+                                         .name("get /api")
+                                         .localEndpoint(Endpoint.newBuilder().serviceName("frontend").build())
+                                         .remoteEndpoint(Endpoint.newBuilder().serviceName("backend").build())
+                                         .timestamp(1_000_000L)
+                                         .duration(2_000L)
+                                         .putTag("http.method", "GET")
+                                         .putTag("http.status_code", "500")
+                                         .putTag("error", "500")
+                                         .addAnnotation(1_000_500L, "ws")
+                                         .build();
+
+    private static QueryRequest.Builder request() {
+        return QueryRequest.newBuilder().endTs(2_000L).lookback(1_000L).limit(10);
+    }
+
+    @Test
+    void shouldMatchAnEmptyRequest() {
+        assertTrue(new ZipkinSpanMatcher(request().build()).matches(SPAN));
+    }
+
+    @Test
+    void shouldMatchServicesNameAndDurationLikeTheQueryDao() {
+        assertTrue(new ZipkinSpanMatcher(request().serviceName("frontend").remoteServiceName("backend")
+                                                  .spanName("get /api").minDuration(2_000L).maxDuration(2_000L).build())
+                       .matches(SPAN));
+        assertFalse(new ZipkinSpanMatcher(request().serviceName("backend").build()).matches(SPAN));
+        assertFalse(new ZipkinSpanMatcher(request().minDuration(2_001L).build()).matches(SPAN));
+        assertFalse(new ZipkinSpanMatcher(request().spanName("post /api").build()).matches(SPAN));
+    }
+
+    @Test
+    void shouldMatchTheAnnotationQueryOnTagsAndAnnotations() {
+        assertTrue(new ZipkinSpanMatcher(request().annotationQuery(new HashMap<>(Map.of("http.method", "GET", "http.status_code", "500"))).build())
+                       .matches(SPAN));
+        // an empty value asks for the key, on a tag or on an annotation value, Zipkin's {status = error} form
+        assertTrue(new ZipkinSpanMatcher(request().annotationQuery(new HashMap<>(Map.of("error", ""))).build()).matches(SPAN));
+        assertTrue(new ZipkinSpanMatcher(request().annotationQuery(new HashMap<>(Map.of("ws", ""))).build()).matches(SPAN));
+        assertFalse(new ZipkinSpanMatcher(request().annotationQuery(new HashMap<>(Map.of("http.status_code", "200"))).build()).matches(SPAN));
+        assertFalse(new ZipkinSpanMatcher(request().annotationQuery(new HashMap<>(Map.of("missing", ""))).build()).matches(SPAN));
+    }
+}
diff --git a/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/handler/TraceQLApiHandlerTest.java b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/handler/TraceQLApiHandlerTest.java
new file mode 100644
index 0000000000..d95d3a8f70
--- /dev/null
+++ b/oap-server/server-query-plugin/traceql-plugin/src/test/java/org/apache/skywalking/oap/query/traceql/handler/TraceQLApiHandlerTest.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.query.traceql.handler;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.apache.skywalking.oap.query.traceql.rt.TraceQLQueryParams;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TraceQLApiHandlerTest {
+    /**
+     * The shared helpers are what is under test; the datasource hooks are not reached.
+     */
+    private static final class StubHandler extends TraceQLApiHandler {
+        @Override
+        protected HttpResponse queryTraceImpl(final String traceId, final Optional<Long> start, final Optional<Long> end,
+                                              final Optional<Boolean> coldStage, final Optional<String> accept) {
+            return HttpResponse.of(HttpStatus.NOT_IMPLEMENTED);
+        }
+
+        @Override
+        protected HttpResponse searchImpl(final Optional<String> query, final Optional<String> tags,
+                                          final Optional<String> minDuration, final Optional<String> maxDuration,
+                                          final Optional<Integer> limit, final Optional<Long> start, final Optional<Long> end,
+                                          final Optional<Integer> spss, final Optional<Boolean> coldStage) {
+            return HttpResponse.of(HttpStatus.NOT_IMPLEMENTED);
+        }
+
+        @Override
+        protected HttpResponse searchTagsImpl(final Optional<String> scope, final Optional<Integer> limit,
+                                              final Optional<Long> start, final Optional<Long> end) {
+            return HttpResponse.of(HttpStatus.NOT_IMPLEMENTED);
+        }
+
+        @Override
+        protected HttpResponse searchTagsV2Impl(final Optional<String> q, final Optional<String> scope,
+                                                final Optional<Integer> limit, final Optional<Long> start,
+                                                final Optional<Long> end) {
+            return HttpResponse.of(HttpStatus.NOT_IMPLEMENTED);
+        }
+
+        @Override
+        protected HttpResponse searchTagValuesImpl(final String tagName, final Optional<String> query,
+                                                   final Optional<Integer> limit, final Optional<Long> start,
+                                                   final Optional<Long> end) {
+            return HttpResponse.of(HttpStatus.NOT_IMPLEMENTED);
+        }
+    }
+
+    private static final TraceQLApiHandler.TagNamesSource THREE_PER_SCOPE =
+        scope -> Arrays.asList(scope + ".a", scope + ".b", scope + ".c");
+
+    @Test
+    void shouldFoldTempoSpellingsOntoTheHandlerNames() {
+        assertEquals("status", TraceQLApiHandler.normalizeTagName("span:status"));
+        assertEquals("resource.service.name", TraceQLApiHandler.normalizeTagName(".service.name"));
+        assertEquals("resource.service.instance.id", TraceQLApiHandler.normalizeTagName(".service.instance.id"));
+        assertEquals("span.http.method", TraceQLApiHandler.normalizeTagName(".http.method"));
+        assertEquals("resource.service.name", TraceQLApiHandler.normalizeTagName("resource.service.name"));
+        assertEquals("span.http.method", TraceQLApiHandler.normalizeTagName("span.http.method"));
+        assertEquals(".", TraceQLApiHandler.normalizeTagName("."));
+    }
+
+    @Test
+    void shouldTellAnEmptyFilterFromAServiceOnlyFilterFromANarrowerOne() {
+        assertNull(TraceQLApiHandler.parseFilter(Optional.empty()));
+        assertNull(TraceQLApiHandler.parseFilter(Optional.of(" ")));
+        assertTrue(TraceQLApiHandler.parseFilter(Optional.of("{ this is not traceql")).hasError());
+
+        final TraceQLQueryParams empty = TraceQLApiHandler.parseFilter(Optional.of("{}")).getParams();
+        assertFalse(TraceQLApiHandler.hasFilter(empty));
+
+        final TraceQLQueryParams serviceOnly = TraceQLApiHandler.parseFilter(Optional.of("{resource.service.name=\"frontend\"}")).getParams();
+        assertTrue(TraceQLApiHandler.hasFilter(serviceOnly));
+        assertTrue(TraceQLApiHandler.onlyServiceName(serviceOnly));
+
+        final TraceQLQueryParams narrower = TraceQLApiHandler.parseFilter(Optional.of("{resource.service.name=\"frontend\" && span.http.method=\"GET\"}")).getParams();
+        assertTrue(TraceQLApiHandler.hasFilter(narrower));
+        assertFalse(TraceQLApiHandler.onlyServiceName(narrower));
+
+        final TraceQLQueryParams star = new TraceQLQueryParams();
+        star.setServiceName("*");
+        assertFalse(TraceQLApiHandler.hasFilter(star));
+    }
+
+    @Test
+    void shouldKeepTheEnumIntrinsicsOutOfTheSampledLookup() {
+        assertTrue(TraceQLApiHandler.isEnumIntrinsic("status"));
+        assertTrue(TraceQLApiHandler.isEnumIntrinsic("kind"));
+        assertFalse(TraceQLApiHandler.isEnumIntrinsic("name"));
+        assertFalse(TraceQLApiHandler.isEnumIntrinsic("span.http.method"));
+    }
+
+    @Test
+    void shouldParseTheDeprecatedTagsParameterAsLogfmt() {
+        final TraceQLQueryParams params = TraceQLApiHandler.parseTagsParameter(
+            "service.name=frontend  span.http.method=GET name=\"GET /\" resource.host.name=web-1 error=true");
+        assertEquals("frontend", params.getServiceName());
+        assertEquals("GET /", params.getSpanName());
+        assertEquals("GET", params.getTags().get("span.http.method"));
+        assertEquals("web-1", params.getTags().get("resource.host.name"));
+        assertEquals("true", params.getTags().get("error"));
+        assertEquals(3, params.getTags().size());
+        assertEquals("GET", params.flatTags().get("http.method"));
+
+        assertThrows(IllegalArgumentException.class, () -> TraceQLApiHandler.parseTagsParameter("http.method"));
+        assertThrows(IllegalArgumentException.class, () -> TraceQLApiHandler.parseTagsParameter("=GET"));
+    }
+
+    @Test
+    void shouldResolveRequestedScopes() {
+        assertEquals(TraceQLApiHandler.ALL_SCOPES, TraceQLApiHandler.requestedScopes(Optional.empty()));
+        assertEquals(Collections.singletonList("span"), TraceQLApiHandler.requestedScopes(Optional.of("SPAN")));
+        assertTrue(TraceQLApiHandler.requestedScopes(Optional.of("event")).isEmpty());
+        assertThrows(IllegalArgumentException.class, () -> TraceQLApiHandler.requestedScopes(Optional.of("bogus")));
+    }
+
+    @Test
+    void shouldTruncateOnlyForAPositiveLimit() {
+        final List<String> values = Arrays.asList("a", "b", "c");
+        assertEquals(values, TraceQLApiHandler.truncate(values, Optional.empty()));
+        assertEquals(values, TraceQLApiHandler.truncate(values, Optional.of(0)));
+        assertEquals(Arrays.asList("a", "b"), TraceQLApiHandler.truncate(values, Optional.of(2)));
+        assertEquals(3, TraceQLApiHandler.spansPerSpanSet(Optional.empty()));
+        assertEquals(3, TraceQLApiHandler.spansPerSpanSet(Optional.of(0)));
+        assertEquals(5, TraceQLApiHandler.spansPerSpanSet(Optional.of(5)));
+    }
+
+    @Test
+    void shouldApplyTheV2LimitPerScope() throws IOException {
+        final JsonNode body = new ObjectMapper().readTree(
+            new StubHandler().tagNamesV2(Optional.empty(), Optional.of(2), THREE_PER_SCOPE).aggregate().join().contentUtf8());
+        assertEquals(3, body.get("scopes").size());
+        for (final JsonNode scope : body.get("scopes")) {
+            assertEquals(2, scope.get("tags").size(), scope.get("name").asText());
+        }
+    }
+
+    @Test
+    void shouldFlattenTheV1ListAndCapItOnce() throws IOException {
+        final JsonNode body = new ObjectMapper().readTree(
+            new StubHandler().tagNames(Optional.empty(), Optional.of(4), THREE_PER_SCOPE).aggregate().join().contentUtf8());
+        assertEquals(4, body.get("tagNames").size());
+        assertEquals("resource.a", body.get("tagNames").get(0).asText());
+        assertEquals("span.a", body.get("tagNames").get(3).asText());
+    }
+
+    @Test
+    void shouldRejectAnUnknownScope() throws IOException {
+        final var response = new StubHandler().tagNamesV2(Optional.of("bogus"), Optional.empty(), THREE_PER_SCOPE).aggregate().join();
+        assertEquals(400, response.status().code());
+        assertTrue(response.contentUtf8().contains("Unsupported scope: bogus"));
+    }
+}
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverConfig.java b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverConfig.java
index 40da30faf0..3cb2b86df5 100644
--- a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverConfig.java
+++ b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverConfig.java
@@ -27,8 +27,20 @@ import org.apache.skywalking.oap.server.library.module.ModuleConfig;
 
 import java.util.List;
 
+/**
+ * The configuration of every OTLP handler. The name is historical; the trace and log handlers read it too.
+ */
 @Slf4j
 public class OtelMetricReceiverConfig extends ModuleConfig {
+    /**
+     * The value of {@link #otlpTraceStorage} that stores OTLP spans natively in {@code otlp_span}.
+     */
+    public static final String OTLP_TRACE_STORAGE_OTLP = "otlp";
+    /**
+     * The value of {@link #otlpTraceStorage} that converts OTLP spans to Zipkin spans and hands them to the Zipkin
+     * receiver, the behavior before native storage existed.
+     */
+    public static final String OTLP_TRACE_STORAGE_ZIPKIN = "zipkin";
 
     @Setter
     private String enabledHandlers;
@@ -36,7 +48,53 @@ public class OtelMetricReceiverConfig extends ModuleConfig {
     @Getter
     private String enabledOtelMetricsRules;
 
+    /**
+     * Where the {@code otlp-traces} handler stores spans, {@link #OTLP_TRACE_STORAGE_OTLP} (the default) or
+     * {@link #OTLP_TRACE_STORAGE_ZIPKIN}.
+     */
+    @Getter
+    @Setter
+    private String otlpTraceStorage = OTLP_TRACE_STORAGE_OTLP;
+
+    /**
+     * Native mode only. Attribute keys offered by tag autocomplete. Every attribute is indexed for equality
+     * search regardless of this list.
+     */
+    @Setter
+    private String otlpTraceSearchableTags;
+
+    /**
+     * Native mode only. Head sampling by trace id, precision 1/10000.
+     */
+    @Getter
+    @Setter
+    private int otlpTraceSampleRate = 10000;
+
+    /**
+     * Native mode only. Spans per second accepted before dropping; 0 means no limit.
+     */
+    @Getter
+    @Setter
+    private int otlpTraceMaxSpansPerSecond = 0;
+
     public List<String> getEnabledHandlers() {
         return Splitter.on(",").trimResults().omitEmptyStrings().splitToList(Strings.nullToEmpty(enabledHandlers));
     }
+
+    public List<String> getOtlpTraceSearchableTags() {
+        return Splitter.on(",").trimResults().omitEmptyStrings().splitToList(Strings.nullToEmpty(otlpTraceSearchableTags));
+    }
+
+    public boolean isOtlpTraceStorageNative() {
+        return OTLP_TRACE_STORAGE_OTLP.equalsIgnoreCase(Strings.nullToEmpty(otlpTraceStorage).trim());
+    }
+
+    /**
+     * Whether {@link #otlpTraceStorage} names one of the two modes; anything else is a typo that must fail the boot
+     * rather than silently select the Zipkin path.
+     */
+    public boolean isOtlpTraceStorageValid() {
+        final String storage = Strings.nullToEmpty(otlpTraceStorage).trim();
+        return OTLP_TRACE_STORAGE_OTLP.equalsIgnoreCase(storage) || OTLP_TRACE_STORAGE_ZIPKIN.equalsIgnoreCase(storage);
+    }
 }
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverProvider.java b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverProvider.java
index c4331099c0..0f4fb99f67 100644
--- a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverProvider.java
+++ b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/OtelMetricReceiverProvider.java
@@ -23,8 +23,11 @@ import org.apache.skywalking.oap.server.library.module.ModuleProvider;
 import org.apache.skywalking.oap.server.library.module.ModuleStartException;
 import org.apache.skywalking.oap.server.library.module.ServiceNotProvidedException;
 import org.apache.skywalking.oap.meter.analyzer.v2.MalConverterRegistry;
+import org.apache.skywalking.oap.server.core.CoreModule;
 import org.apache.skywalking.oap.server.core.storage.StorageModule;
 import org.apache.skywalking.oap.server.receiver.otel.otlp.OpenTelemetryMetricRequestProcessor;
+import org.apache.skywalking.oap.server.receiver.otel.otlp.OpenTelemetryTraceHandler;
+import org.apache.skywalking.oap.server.receiver.zipkin.ZipkinReceiverModule;
 import org.apache.skywalking.oap.server.receiver.sharing.server.SharingServerModule;
 
 import java.util.ArrayList;
@@ -67,6 +70,11 @@ public class OtelMetricReceiverProvider extends ModuleProvider {
 
     @Override
     public void prepare() throws ServiceNotProvidedException, ModuleStartException {
+        if (!config.isOtlpTraceStorageValid()) {
+            throw new ModuleStartException(
+                "receiver-otel.default.otlpTraceStorage must be `" + OtelMetricReceiverConfig.OTLP_TRACE_STORAGE_ZIPKIN
+                    + "` or `" + OtelMetricReceiverConfig.OTLP_TRACE_STORAGE_OTLP + "`, got `" + config.getOtlpTraceStorage() + "`");
+        }
         metricRequestProcessor = new OpenTelemetryMetricRequestProcessor(
             getManager(), config);
         registerServiceImplementation(OpenTelemetryMetricRequestProcessor.class, metricRequestProcessor);
@@ -107,6 +115,15 @@ public class OtelMetricReceiverProvider extends ModuleProvider {
         // module-system sort could place OTEL ahead of Storage, the resolver would
         // silently no-op at boot, and DB overrides would only take effect on the
         // reconciler's next tick.
-        return new String[] {SharingServerModule.NAME, StorageModule.NAME};
+        // CoreModule: the trace handler reaches SourceReceiver, NamingControl and SpanListenerManager through the
+        // module manager, which is only legal for a declared dependency.
+        final List<String> required = new ArrayList<>(List.of(SharingServerModule.NAME, StorageModule.NAME, CoreModule.NAME));
+        // The Zipkin mode of otlp-traces hands spans to receiver-zipkin's SpanForwardService; declaring it here makes
+        // a boot without that module fail with the module system's own error instead of on the first export, while
+        // the native mode keeps not needing it.
+        if (config.getEnabledHandlers().contains(OpenTelemetryTraceHandler.TYPE) && !config.isOtlpTraceStorageNative()) {
+            required.add(ZipkinReceiverModule.NAME);
+        }
+        return required.toArray(new String[0]);
     }
 }
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanForward.java b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanForward.java
new file mode 100644
index 0000000000..a1af151fa0
--- /dev/null
+++ b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanForward.java
@@ -0,0 +1,278 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.receiver.otel.otlp;
+
+import com.google.common.util.concurrent.RateLimiter;
+import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.InstrumentationScope;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
+import io.opentelemetry.proto.trace.v1.ScopeSpans;
+import io.opentelemetry.proto.trace.v1.Span;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.analysis.TimeBucket;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.TagType;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.otlp.OTLPSpanRecord;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPService;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPServiceRelation;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPServiceSpan;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPSpan;
+import org.apache.skywalking.oap.server.core.source.SourceReceiver;
+import org.apache.skywalking.oap.server.core.source.TagAutocomplete;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+import org.apache.skywalking.oap.server.receiver.otel.OtelMetricReceiverConfig;
+
+/**
+ * Stores OTLP spans natively. The counterpart of the Zipkin receiver's {@code SpanForward}: head sampling and the
+ * rate limit, the index columns, the {@code key=value} search index, tag autocomplete, the name catalogs, and
+ * the {@code data_binary} that keeps the span exactly as the SDK sent it.
+ */
+@Slf4j
+public class OTLPSpanForward {
+    /**
+     * The attribute the remote-service catalog is built from, per the OpenTelemetry semantic conventions.
+     */
+    public static final String PEER_SERVICE = OTLPSpanRecord.PEER_SERVICE_ATTRIBUTE;
+    /**
+     * The tag autocomplete key the instrumentation scope name is always published under, so TraceQL can list
+     * {@code otel.scope.name} values without configuration.
+     */
+    public static final String SCOPE_NAME_TAG = "otel.scope.name";
+    /**
+     * The tag autocomplete key the resolved instance is always published under, so TraceQL's
+     * {@code resource.service.instance.id} dropdown fills regardless of {@code otlpTraceSearchableTags}.
+     */
+    public static final String INSTANCE_TAG = "service.instance.id";
+    private static final int TRACE_ID_LENGTH = 16;
+
+    private final ModuleManager moduleManager;
+    private final Set<String> searchableTagKeys;
+    private final long samplerBoundary;
+    private final boolean sampleAll;
+    private final RateLimiter rateLimiter;
+    private NamingControl namingControl;
+    private SourceReceiver receiver;
+
+    public OTLPSpanForward(final OtelMetricReceiverConfig config, final ModuleManager moduleManager) {
+        this.moduleManager = moduleManager;
+        this.searchableTagKeys = new HashSet<>(config.getOtlpTraceSearchableTags());
+        final float sampleRate = (float) config.getOtlpTraceSampleRate() / 10000;
+        this.samplerBoundary = (long) (Long.MAX_VALUE * sampleRate);
+        this.sampleAll = config.getOtlpTraceSampleRate() == 10000;
+        this.rateLimiter = config.getOtlpTraceMaxSpansPerSecond() > 0
+            ? RateLimiter.create(config.getOtlpTraceMaxSpansPerSecond()) : null;
+    }
+
+    /**
+     * Index and store one span.
+     *
+     * @param resource       the span's resource, stored verbatim
+     * @param scope          the span's instrumentation scope, stored verbatim
+     * @param span           the span exactly as received
+     * @param serviceName    the service name resolved from the resource, not yet formatted
+     * @param resourceTags   the resource attributes rendered as strings, see {@link OTLPValues#toStringMap}
+     * @param additionalTags attributes the span listeners injected; appended to the stored span as strings, the
+     *                       only way the stored span differs from the wire span
+     * @return false when head sampling or the rate limit dropped the span
+     */
+    public boolean send(final Resource resource,
+                        final String resourceSchemaUrl,
+                        final InstrumentationScope scope,
+                        final String scopeSchemaUrl,
+                        final Span span,
+                        final String serviceName,
+                        final Map<String, String> resourceTags,
+                        final Map<String, String> additionalTags) {
+        if (!sampled(span)) {
+            return false;
+        }
+        final Span stored = appendAttributes(span, additionalTags);
+        final String formattedServiceName = getNamingControl().formatServiceName(serviceName);
+        final long startTimeMillis = TimeUnit.NANOSECONDS.toMillis(stored.getStartTimeUnixNano());
+        final long minuteTimeBucket = TimeBucket.getMinuteTimeBucket(startTimeMillis);
+
+        final OTLPSpan source = new OTLPSpan();
+        source.setTraceId(OTLPValues.hexId(stored.getTraceId()));
+        source.setSpanId(OTLPValues.hexId(stored.getSpanId()));
+        source.setParentSpanId(OTLPValues.hexId(stored.getParentSpanId()));
+        source.setServiceName(formattedServiceName);
+        final String serviceInstance = OTLPSpanRecord.SERVICE_INSTANCE_RESOURCE_KEYS.stream()
+                                                                                  .map(resourceTags::get)
+                                                                                  .filter(StringUtil::isNotEmpty)
+                                                                                  .findFirst()
+                                                                                  .orElse("");
+        source.setServiceInstance(serviceInstance);
+        source.setScopeName(scope.getName());
+        source.setName(getNamingControl().formatEndpointName(formattedServiceName, stored.getName()));
+        source.setKind(stored.getKindValue());
+        source.setStatusCode(stored.getStatus().getCodeValue());
+        source.setStartTime(startTimeMillis);
+        source.setDuration(Math.max(0L, stored.getEndTimeUnixNano() - stored.getStartTimeUnixNano()));
+        source.setTimeBucket(TimeBucket.getRecordTimeBucket(startTimeMillis));
+
+        final List<String> tags = new ArrayList<>(resource.getAttributesCount() + stored.getAttributesCount());
+        indexAttributes(resource.getAttributesList(), OTLPSpanRecord.RESOURCE_TAG_PREFIX, tags, minuteTimeBucket);
+        final String peerService = indexAttributes(
+            stored.getAttributesList(), OTLPSpanRecord.SPAN_TAG_PREFIX, tags, minuteTimeBucket);
+        source.setTags(tags);
+        if (StringUtil.isNotEmpty(peerService)) {
+            source.setPeerService(getNamingControl().formatServiceName(peerService));
+        }
+        if (StringUtil.isNotEmpty(scope.getName())) {
+            addAutocompleteTag(minuteTimeBucket, SCOPE_NAME_TAG, scope.getName());
+        }
+        if (StringUtil.isNotEmpty(serviceInstance)) {
+            addAutocompleteTag(minuteTimeBucket, INSTANCE_TAG, serviceInstance);
+        }
+
+        source.setDataBinary(ResourceSpans.newBuilder()
+                                          .setResource(resource)
+                                          .setSchemaUrl(resourceSchemaUrl)
+                                          .addScopeSpans(ScopeSpans.newBuilder()
+                                                                   .setScope(scope)
+                                                                   .setSchemaUrl(scopeSchemaUrl)
+                                                                   .addSpans(stored))
+                                          .build()
+                                          .toByteArray());
+        getReceiver().receive(source);
+
+        final OTLPService service = new OTLPService();
+        service.setServiceName(formattedServiceName);
+        service.setTimeBucket(minuteTimeBucket);
+        getReceiver().receive(service);
+
+        final OTLPServiceSpan serviceSpan = new OTLPServiceSpan();
+        serviceSpan.setServiceName(formattedServiceName);
+        serviceSpan.setSpanName(source.getName());
+        serviceSpan.setTimeBucket(minuteTimeBucket);
+        getReceiver().receive(serviceSpan);
+
+        if (StringUtil.isNotEmpty(source.getPeerService())) {
+            final OTLPServiceRelation relation = new OTLPServiceRelation();
+            relation.setServiceName(formattedServiceName);
+            relation.setPeerService(source.getPeerService());
+            relation.setTimeBucket(minuteTimeBucket);
+            getReceiver().receive(relation);
+        }
+        return true;
+    }
+
+    /**
+     * Head sampling on the low 64 bits of the trace id, the arithmetic {@code SpanForward.getSampledTraces} uses,
+     * so a trace is kept or dropped as a whole on every OAP node. The rate limit is applied first.
+     */
+    private boolean sampled(final Span span) {
+        if (rateLimiter != null && !rateLimiter.tryAcquire()) {
+            log.debug("Span dropped due to the maximum spans per second limit: {}", OTLPValues.hexId(span.getSpanId()));
+            return false;
+        }
+        if (sampleAll) {
+            return true;
+        }
+        final byte[] traceId = span.getTraceId().toByteArray();
+        if (traceId.length < 8) {
+            return true;
+        }
+        final int lowOffset = traceId.length >= TRACE_ID_LENGTH ? 8 : traceId.length - 8;
+        long low = ByteBuffer.wrap(traceId, lowOffset, 8).getLong();
+        low = low == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(low);
+        return low <= samplerBoundary;
+    }
+
+    private static Span appendAttributes(final Span span, final Map<String, String> additionalTags) {
+        if (additionalTags == null || additionalTags.isEmpty()) {
+            return span;
+        }
+        final Span.Builder builder = span.toBuilder();
+        additionalTags.forEach((key, value) -> builder.addAttributes(
+            KeyValue.newBuilder()
+                    .setKey(key)
+                    .setValue(AnyValue.newBuilder().setStringValue(value))
+        ));
+        return builder.build();
+    }
+
+    /**
+     * Append every attribute as {@code <scope>.key=value} to the search index, skipping entries longer than
+     * {@link Tag#TAG_LENGTH}, and publish the configured keys to tag autocomplete, which stays scope-less: it only
+     * feeds the value dropdowns.
+     *
+     * @param scopePrefix {@link OTLPSpanRecord#RESOURCE_TAG_PREFIX} or {@link OTLPSpanRecord#SPAN_TAG_PREFIX}
+     * @return the {@code peer.service} value, or null when the attributes carry none
+     */
+    private String indexAttributes(final List<KeyValue> attributes,
+                                   final String scopePrefix,
+                                   final List<String> tags,
+                                   final long minuteTimeBucket) {
+        String peerService = null;
+        for (final KeyValue attribute : attributes) {
+            final String value = OTLPValues.render(attribute.getValue());
+            if (PEER_SERVICE.equals(attribute.getKey())) {
+                peerService = value;
+            }
+            final String tag = scopePrefix + attribute.getKey() + "=" + value;
+            if (tag.length() > Tag.TAG_LENGTH) {
+                if (log.isDebugEnabled()) {
+                    log.debug("Span attribute {} length > {}, not indexed", tag, Tag.TAG_LENGTH);
+                }
+                continue;
+            }
+            tags.add(tag);
+            if (searchableTagKeys.contains(attribute.getKey())) {
+                addAutocompleteTag(minuteTimeBucket, attribute.getKey(), value);
+            }
+        }
+        return peerService;
+    }
+
+    private void addAutocompleteTag(final long minuteTimeBucket, final String key, final String value) {
+        final TagAutocomplete tagAutocomplete = new TagAutocomplete();
+        tagAutocomplete.setTagKey(key);
+        tagAutocomplete.setTagValue(value);
+        tagAutocomplete.setTagType(TagType.OTLP);
+        tagAutocomplete.setTimeBucket(minuteTimeBucket);
+        getReceiver().receive(tagAutocomplete);
+    }
+
+    private NamingControl getNamingControl() {
+        if (namingControl == null) {
+            namingControl = moduleManager.find(CoreModule.NAME).provider().getService(NamingControl.class);
+        }
+        return namingControl;
+    }
+
+    private SourceReceiver getReceiver() {
+        if (receiver == null) {
+            receiver = moduleManager.find(CoreModule.NAME).provider().getService(SourceReceiver.class);
+        }
+        return receiver;
+    }
+}
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanReaderImpl.java b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanReaderImpl.java
index 6880e20c14..4aefecd15b 100644
--- a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanReaderImpl.java
+++ b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanReaderImpl.java
@@ -17,10 +17,10 @@
 
 package org.apache.skywalking.oap.server.receiver.otel.otlp;
 
-import com.google.protobuf.ByteString;
 import io.opentelemetry.proto.common.v1.AnyValue;
 import io.opentelemetry.proto.common.v1.KeyValue;
 import io.opentelemetry.proto.trace.v1.Span;
+import java.util.Map;
 import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
 
 /**
@@ -29,6 +29,7 @@ import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
 public class OTLPSpanReaderImpl implements OTLPSpanReader {
 
     private final Span span;
+    private Map<String, String> attributes;
 
     public OTLPSpanReaderImpl(final Span span) {
         this.span = span;
@@ -36,12 +37,17 @@ public class OTLPSpanReaderImpl implements OTLPSpanReader {
 
     @Override
     public String traceId() {
-        return idToHexString(span.getTraceId());
+        return OTLPValues.hexId(span.getTraceId());
     }
 
     @Override
     public String spanId() {
-        return idToHexString(span.getSpanId());
+        return OTLPValues.hexId(span.getSpanId());
+    }
+
+    @Override
+    public String parentSpanId() {
+        return OTLPValues.hexId(span.getParentSpanId());
     }
 
     @Override
@@ -54,6 +60,11 @@ public class OTLPSpanReaderImpl implements OTLPSpanReader {
         return span.getKind().name();
     }
 
+    @Override
+    public String statusCode() {
+        return span.getStatus().getCode().name();
+    }
+
     @Override
     public long startTimeNanos() {
         return span.getStartTimeUnixNano();
@@ -74,6 +85,21 @@ public class OTLPSpanReaderImpl implements OTLPSpanReader {
         return "";
     }
 
+    /**
+     * Built on first use and reused: listeners that read many attributes pay one pass over the list.
+     */
+    @Override
+    public Map<String, String> attributes() {
+        if (attributes == null) {
+            attributes = OTLPValues.toStringMap(span.getAttributesList());
+        }
+        return attributes;
+    }
+
+    /**
+     * Scalars only, the historical contract of {@link #getAttribute(String)}: arrays, key-value lists and bytes
+     * read as empty here and as text through {@link #attributes()}.
+     */
     private static String convertValue(final AnyValue value) {
         if (value.hasStringValue()) {
             return value.getStringValue();
@@ -89,18 +115,4 @@ public class OTLPSpanReaderImpl implements OTLPSpanReader {
         }
         return "";
     }
-
-    private static String idToHexString(final ByteString id) {
-        if (id == null || id.isEmpty()) {
-            return "";
-        }
-
-        final char[] hex = new char[id.size() * 2];
-        for (int i = 0; i < id.size(); i++) {
-            final int value = id.byteAt(i) & 0xff;
-            hex[i * 2] = Character.forDigit(value >>> 4, 16);
-            hex[i * 2 + 1] = Character.forDigit(value & 0x0f, 16);
-        }
-        return new String(hex);
-    }
 }
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryTraceHTTPHandler.java b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryTraceHTTPHandler.java
index 32f6e81189..e0cd4f3944 100644
--- a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryTraceHTTPHandler.java
+++ b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryTraceHTTPHandler.java
@@ -39,9 +39,6 @@ import lombok.extern.slf4j.Slf4j;
 @Slf4j
 @RequiredArgsConstructor
 public class OpenTelemetryTraceHTTPHandler {
-    private static final byte[] EMPTY_RESPONSE =
-        ExportTraceServiceResponse.getDefaultInstance().toByteArray();
-
     private final OpenTelemetryTraceHandler traceHandler;
 
     @Blocking
@@ -51,8 +48,8 @@ public class OpenTelemetryTraceHTTPHandler {
         try {
             final ExportTraceServiceRequest exportRequest =
                 ExportTraceServiceRequest.parseFrom(request.content().array());
-            traceHandler.processExport(exportRequest);
-            return HttpResponse.of(HttpStatus.OK, MediaType.PROTOBUF, EMPTY_RESPONSE);
+            final ExportTraceServiceResponse response = traceHandler.processExport(exportRequest);
+            return HttpResponse.of(HttpStatus.OK, MediaType.PROTOBUF, response.toByteArray());
         } catch (InvalidProtocolBufferException e) {
             log.warn("Failed to parse OTLP/HTTP trace request", e);
             return HttpResponse.of(HttpStatus.BAD_REQUEST);
@@ -71,8 +68,11 @@ public class OpenTelemetryTraceHTTPHandler {
                 ExportTraceServiceRequest.newBuilder();
             JsonFormat.parser().ignoringUnknownFields().merge(
                 request.contentUtf8(), builder);
-            traceHandler.processExport(builder.build());
-            return HttpResponse.of(HttpStatus.OK, MediaType.JSON_UTF_8, "{}");
+            final ExportTraceServiceResponse response = traceHandler.processExport(builder.build());
+            // An empty response prints as `{}`, which is what this endpoint always answered before.
+            return HttpResponse.of(
+                HttpStatus.OK, MediaType.JSON_UTF_8,
+                JsonFormat.printer().omittingInsignificantWhitespace().print(response));
         } catch (InvalidProtocolBufferException e) {
             log.warn("Failed to parse OTLP/HTTP JSON trace request", e);
             return HttpResponse.of(HttpStatus.BAD_REQUEST);
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryTraceHandler.java b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryTraceHandler.java
index a03e9a4045..44ac95f1ed 100644
--- a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryTraceHandler.java
+++ b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryTraceHandler.java
@@ -21,18 +21,20 @@ package org.apache.skywalking.oap.server.receiver.otel.otlp;
 import com.google.gson.JsonObject;
 import com.google.protobuf.ByteString;
 import io.grpc.stub.StreamObserver;
+import io.opentelemetry.proto.collector.trace.v1.ExportTracePartialSuccess;
 import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
 import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse;
 import io.opentelemetry.proto.collector.trace.v1.TraceServiceGrpc;
-import io.opentelemetry.proto.common.v1.AnyValue;
 import io.opentelemetry.proto.common.v1.InstrumentationScope;
 import io.opentelemetry.proto.common.v1.KeyValue;
 import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
 import io.opentelemetry.proto.trace.v1.ScopeSpans;
 import io.opentelemetry.proto.trace.v1.Status;
 import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.otlp.OTLPSpanRecord;
 import org.apache.skywalking.oap.server.core.trace.SpanListenerManager;
 import org.apache.skywalking.oap.server.core.trace.SpanListenerResult;
 import com.linecorp.armeria.common.HttpMethod;
@@ -57,23 +59,32 @@ import zipkin2.Span;
 
 import java.math.BigInteger;
 import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
-import java.util.Base64;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
-import java.util.stream.Collectors;
 
+/**
+ * The {@code otlp-traces} handler. Every span goes through the phase-one {@code SpanListener}s, then either is
+ * stored natively by {@link OTLPSpanForward} ({@code otlpTraceStorage: otlp}, the default) or, with
+ * {@code otlpTraceStorage: zipkin}, converted to a Zipkin span and handed to the Zipkin receiver. One handler, two
+ * backends: two trace handlers would both register {@code TraceServiceGrpc} on the shared gRPC server.
+ */
 @Slf4j
 public class OpenTelemetryTraceHandler
     extends TraceServiceGrpc.TraceServiceImplBase
     implements Handler {
+    public static final String TYPE = "otlp-traces";
+    private static final String NO_SERVICE_NAME_MESSAGE =
+        "resource carries none of service.name, faas.name, k8s.deployment.name, process.executable.name";
+
     private ModuleManager manager;
+    private OtelMetricReceiverConfig config;
     private SpanForwardService forwardService;
+    private OTLPSpanForward otlpSpanForward;
     private SpanListenerManager spanListenerManager;
 
     @Getter(lazy = true)
@@ -97,11 +108,12 @@ public class OpenTelemetryTraceHandler
     @Override
     public void init(ModuleManager manager, OtelMetricReceiverConfig config) {
         this.manager = manager;
+        this.config = config;
     }
 
     @Override
     public String type() {
-        return "otlp-traces";
+        return TYPE;
     }
 
     @Override
@@ -117,48 +129,79 @@ public class OpenTelemetryTraceHandler
         httpHandlerRegister.addHandler(
             new OpenTelemetryTraceHTTPHandler(this),
             Collections.singletonList(HttpMethod.POST));
+
+        if (config.isOtlpTraceStorageNative()) {
+            // Built here, once, so the rate limiter is shared by every export; a lazy build raced on the first two.
+            otlpSpanForward = new OTLPSpanForward(config, manager);
+        }
     }
 
     @Override
     public void export(ExportTraceServiceRequest request, StreamObserver<ExportTraceServiceResponse> responseObserver) {
-        processExport(request);
-        responseObserver.onNext(ExportTraceServiceResponse.getDefaultInstance());
+        responseObserver.onNext(processExport(request));
         responseObserver.onCompleted();
     }
 
     /**
      * Process an OTLP trace export request. Shared by both gRPC and HTTP handlers.
+     *
+     * @return the export response. In native storage mode it reports the spans rejected for a resource without a
+     * service name through {@code partial_success}, as the OTLP specification requires for rejected data; spans
+     * dropped by head sampling or the rate limit are not rejections and stay visible through
+     * {@code otel_spans_dropped}. The Zipkin mode keeps answering an empty response.
      */
-    void processExport(ExportTraceServiceRequest request) {
+    ExportTraceServiceResponse processExport(ExportTraceServiceRequest request) {
+        final boolean nativeStorage = config != null && config.isOtlpTraceStorageNative();
         final ArrayList<Span> result = new ArrayList<>();
+        long rejectedSpans = 0;
 
         try (final var unused = getProcessHistogram().createTimer()) {
-            request.getResourceSpansList().forEach(resourceSpans -> {
+            for (final ResourceSpans resourceSpans : request.getResourceSpansList()) {
                 final Resource resource = resourceSpans.getResource();
                 final List<ScopeSpans> scopeSpansList = resourceSpans.getScopeSpansList();
                 if (resource.getAttributesCount() == 0 && scopeSpansList.size() == 0) {
-                    return;
+                    continue;
                 }
-                final Map<String, String> resourceTags = convertAttributeToMap(resource.getAttributesList());
-                String serviceName = extractZipkinServiceName(resourceTags);
+                final Map<String, String> resourceTags = OTLPValues.toStringMap(resource.getAttributesList());
+                // The native path fills the service_name column only and leaves the attributes as sent; the Zipkin
+                // path rewrites the fallback key into an `otlp.service.name.source` tag.
+                final String serviceName = nativeStorage
+                    ? resolveServiceName(resourceTags) : extractZipkinServiceName(resourceTags);
                 if (StringUtil.isEmpty(serviceName)) {
                     log.warn("No service name found in resource attributes, discarding the trace");
-                    return;
+                    if (nativeStorage) {
+                        rejectedSpans += countSpans(scopeSpansList);
+                    }
+                    continue;
                 }
 
                 try {
                     for (ScopeSpans scopeSpans : scopeSpansList) {
-                        final String scopeName = scopeSpans.getScope().getName();
-                        final String scopeVersion = scopeSpans.getScope().getVersion();
-                        extractScopeTag(scopeSpans.getScope(), resourceTags);
+                        final InstrumentationScope scope = scopeSpans.getScope();
+                        final String scopeName = scope.getName();
+                        final String scopeVersion = scope.getVersion();
+                        if (!nativeStorage) {
+                            extractScopeTag(scope, resourceTags);
+                        }
                         for (io.opentelemetry.proto.trace.v1.Span span : scopeSpans.getSpansList()) {
-                            // Phase 1: notify OTLP span listeners before Zipkin conversion
+                            if (nativeStorage) {
+                                // The response reports the batch as accepted, so one span's failure must not take the
+                                // rest of the export with it; what is not stored is counted as dropped.
+                                try {
+                                    storeNative(resourceSpans, scopeSpans, span, serviceName, resourceTags);
+                                } catch (Exception e) {
+                                    log.warn("Storing OTLP span {} failed, dropping it: {}",
+                                             OTLPValues.hexId(span.getSpanId()), e.getMessage());
+                                    getDroppedSpans().inc(1);
+                                }
+                                continue;
+                            }
+                            final OTLPSpanReaderImpl reader = new OTLPSpanReaderImpl(span);
+                            // Phase 1: notify OTLP span listeners before conversion or storage
                             final SpanListenerResult otlpResult = getSpanListenerManager()
-                                .notifyOTLPPhase(
-                                    new OTLPSpanReaderImpl(span),
-                                    resourceTags, scopeName, scopeVersion);
+                                .notifyOTLPPhase(reader, resourceTags, scopeName, scopeVersion);
 
-                            // If any listener vetoed persistence, skip Zipkin conversion
+                            // If any listener vetoed persistence, skip conversion and storage
                             if (!otlpResult.isShouldPersist()) {
                                 continue;
                             }
@@ -180,12 +223,31 @@ public class OpenTelemetryTraceHandler
                 } catch (Exception e) {
                     log.warn("convert span error, discarding the span: {}", e.getMessage());
                 }
-            });
-            final var processedSpans = getForwardService().send(result);
-            if (processedSpans.size() < result.size()) {
-                getDroppedSpans().inc(result.size() - processedSpans.size());
+            }
+            if (!nativeStorage) {
+                final var processedSpans = getForwardService().send(result);
+                if (processedSpans.size() < result.size()) {
+                    getDroppedSpans().inc(result.size() - processedSpans.size());
+                }
             }
         }
+
+        if (rejectedSpans == 0) {
+            return ExportTraceServiceResponse.getDefaultInstance();
+        }
+        return ExportTraceServiceResponse.newBuilder()
+                                         .setPartialSuccess(ExportTracePartialSuccess.newBuilder()
+                                                                                     .setRejectedSpans(rejectedSpans)
+                                                                                     .setErrorMessage(NO_SERVICE_NAME_MESSAGE))
+                                         .build();
+    }
+
+    private static long countSpans(final List<ScopeSpans> scopeSpansList) {
+        long count = 0;
+        for (final ScopeSpans scopeSpans : scopeSpansList) {
+            count += scopeSpans.getSpansCount();
+        }
+        return count;
     }
 
     private Span convertSpan(io.opentelemetry.proto.trace.v1.Span span, String serviceName, Map<String, String> resourceTags) {
@@ -245,7 +307,7 @@ public class OpenTelemetryTraceHandler
                 return;
             }
 
-            final JsonObject attrObj = convertToString(event.getAttributesList());
+            final JsonObject attrObj = OTLPValues.renderKvList(event.getAttributesList());
             spanBuilder.addAnnotation(eventTime,
                 event.getName() + "|" + attrObj + "|" + event.getDroppedAttributesCount());
         });
@@ -256,11 +318,15 @@ public class OpenTelemetryTraceHandler
             final io.opentelemetry.proto.trace.v1.Span.Link link = links.get(i);
             tags.put("otlp.link." + i,
                 idToHexString(link.getTraceId()) + "|" + idToHexString(link.getSpanId()) + "|" +
-                link.getTraceState() + "|" + convertToString(link.getAttributesList()) + "|" +
+                link.getTraceState() + "|" + OTLPValues.renderKvList(link.getAttributesList()) + "|" +
                 link.getDroppedAttributesCount());
         }
     }
 
+    /**
+     * The Zipkin path has always written link ids in this decimal spelling; kept as is so stored tags stay
+     * comparable across versions. The native path stores the link bytes themselves.
+     */
     private String idToHexString(ByteString id) {
         if (id == null) {
             return "";
@@ -361,7 +427,7 @@ public class OpenTelemetryTraceHandler
     private Map<String, String> aggregateSpanTags(List<KeyValue> spanAttrs, Map<String, String> resourceTags) {
         final HashMap<String, String> result = new HashMap<>();
         result.putAll(resourceTags);
-        result.putAll(convertAttributeToMap(spanAttrs));
+        result.putAll(OTLPValues.toStringMap(spanAttrs));
         return result;
     }
 
@@ -378,12 +444,18 @@ public class OpenTelemetryTraceHandler
         }
     }
 
-    private Map<String, String> convertAttributeToMap(List<KeyValue> attrs) {
-        return attrs.stream().collect(Collectors.toMap(
-            KeyValue::getKey,
-            attributeKeyValue -> convertToString(attributeKeyValue.getValue()),
-            (v1, v2) -> v1
-        ));
+    /**
+     * The service name for the native store: the same fallback order as {@link #extractZipkinServiceName}, but
+     * the attributes are left exactly as sent.
+     */
+    private static String resolveServiceName(Map<String, String> resourceTags) {
+        for (final String key : OTLPSpanRecord.SERVICE_NAME_RESOURCE_KEYS) {
+            final String name = resourceTags.get(key);
+            if (StringUtil.isNotEmpty(name)) {
+                return name;
+            }
+        }
+        return "";
     }
 
     private String extractZipkinServiceName(Map<String, String> resourceTags) {
@@ -412,38 +484,6 @@ public class OpenTelemetryTraceHandler
         return "";
     }
 
-    private String convertToString(AnyValue value) {
-        if (value == null) {
-            return "";
-        }
-
-        if (value.hasBoolValue()) {
-            return String.valueOf(value.getBoolValue());
-        } else if (value.hasDoubleValue()) {
-            return String.valueOf(value.getDoubleValue());
-        } else if (value.hasStringValue()) {
-            return value.getStringValue();
-        } else if (value.hasArrayValue()) {
-            return value.getArrayValue().getValuesList().stream().map(this::convertToString).collect(Collectors.joining(","));
-        } else if (value.hasIntValue()) {
-            return String.valueOf(value.getIntValue());
-        } else if (value.hasKvlistValue()) {
-            final JsonObject kvObj = convertToString(value.getKvlistValue().getValuesList());
-            return kvObj.toString();
-        } else if (value.hasBytesValue()) {
-            return new String(Base64.getEncoder().encode(value.getBytesValue().toByteArray()), StandardCharsets.UTF_8);
-        }
-        return "";
-    }
-
-    private JsonObject convertToString(List<KeyValue> keyValues) {
-        final JsonObject json = new JsonObject();
-        for (KeyValue keyValue : keyValues) {
-            json.addProperty(keyValue.getKey(), convertToString(keyValue.getValue()));
-        }
-        return json;
-    }
-
     private SpanForwardService getForwardService() {
         if (forwardService == null) {
             forwardService = manager.find(ZipkinReceiverModule.NAME).provider().getService(SpanForwardService.class);
@@ -451,11 +491,42 @@ public class OpenTelemetryTraceHandler
         return forwardService;
     }
 
+    /**
+     * The native path for one span: both listener phases, then the store. Exceptions propagate to the caller, which
+     * drops this span only.
+     */
+    private void storeNative(final ResourceSpans resourceSpans,
+                             final ScopeSpans scopeSpans,
+                             final io.opentelemetry.proto.trace.v1.Span span,
+                             final String serviceName,
+                             final Map<String, String> resourceTags) {
+        final InstrumentationScope scope = scopeSpans.getScope();
+        final OTLPSpanReaderImpl reader = new OTLPSpanReaderImpl(span);
+        // Phase 1: the listeners every OTLP span goes through
+        final SpanListenerResult otlpResult = getSpanListenerManager()
+            .notifyOTLPPhase(reader, resourceTags, scope.getName(), scope.getVersion());
+        if (!otlpResult.isShouldPersist()) {
+            return;
+        }
+        // Phase 2 for natively stored spans: no Zipkin span will ever exist for them.
+        final SpanListenerResult storedResult = getSpanListenerManager()
+            .notifyNativeOTLPPhase(reader, resourceTags, scope.getName(), scope.getVersion());
+        if (!storedResult.isShouldPersist()) {
+            return;
+        }
+        final Map<String, String> injectedTags = new HashMap<>(otlpResult.getAdditionalTags());
+        injectedTags.putAll(storedResult.getAdditionalTags());
+        if (!otlpSpanForward.send(
+            resourceSpans.getResource(), resourceSpans.getSchemaUrl(), scope, scopeSpans.getSchemaUrl(),
+            span, serviceName, resourceTags, injectedTags)) {
+            getDroppedSpans().inc(1);
+        }
+    }
+
     private SpanListenerManager getSpanListenerManager() {
         if (spanListenerManager == null) {
             spanListenerManager = manager.find(CoreModule.NAME).provider().getService(SpanListenerManager.class);
         }
         return spanListenerManager;
     }
-
 }
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/test/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanForwardTest.java b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/test/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanForwardTest.java
new file mode 100644
index 0000000000..c8b710913a
--- /dev/null
+++ b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/test/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPSpanForwardTest.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.server.receiver.otel.otlp;
+
+import com.google.protobuf.ByteString;
+import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.InstrumentationScope;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
+import io.opentelemetry.proto.trace.v1.Span;
+import io.opentelemetry.proto.trace.v1.Status;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.TagType;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPService;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPServiceRelation;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPServiceSpan;
+import org.apache.skywalking.oap.server.core.otlp.source.OTLPSpan;
+import org.apache.skywalking.oap.server.core.source.ISource;
+import org.apache.skywalking.oap.server.core.source.SourceReceiver;
+import org.apache.skywalking.oap.server.core.source.TagAutocomplete;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.library.module.ModuleProviderHolder;
+import org.apache.skywalking.oap.server.library.module.ModuleServiceHolder;
+import org.apache.skywalking.oap.server.receiver.otel.OtelMetricReceiverConfig;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class OTLPSpanForwardTest {
+    private static final byte[] TRACE_ID = {
+        0x0a, (byte) 0xf7, 0x65, 0x19, 0x16, (byte) 0xcd, 0x43, (byte) 0xdd,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01
+    };
+    private static final byte[] SPAN_ID = {0x00, (byte) 0xb7, (byte) 0xad, 0x6b, 0x71, 0x69, 0x20, 0x33};
+    private static final byte[] PARENT_SPAN_ID = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
+    private static final long START_NANOS = 1_700_000_000_123_456_789L;
+
+    private ModuleManager moduleManager;
+    private SourceReceiver receiver;
+    private OtelMetricReceiverConfig config;
+
+    @BeforeEach
+    void setUp() {
+        moduleManager = mock(ModuleManager.class);
+        final ModuleProviderHolder providerHolder = mock(ModuleProviderHolder.class);
+        final ModuleServiceHolder serviceHolder = mock(ModuleServiceHolder.class);
+        final NamingControl namingControl = mock(NamingControl.class);
+        receiver = mock(SourceReceiver.class);
+        when(moduleManager.find(CoreModule.NAME)).thenReturn(providerHolder);
+        when(providerHolder.provider()).thenReturn(serviceHolder);
+        when(serviceHolder.getService(NamingControl.class)).thenReturn(namingControl);
+        when(serviceHolder.getService(SourceReceiver.class)).thenReturn(receiver);
+        when(namingControl.formatServiceName(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
+        when(namingControl.formatEndpointName(anyString(), anyString())).thenAnswer(invocation -> invocation.getArgument(1));
+
+        config = new OtelMetricReceiverConfig();
+        config.setOtlpTraceSearchableTags("http.request.method,peer.service");
+    }
+
+    @Test
+    void shouldIndexColumnsCatalogsAndKeepTheSpanVerbatim() throws Exception {
+        final OTLPSpanForward forward = new OTLPSpanForward(config, moduleManager);
+        final Resource resource = Resource.newBuilder()
+                                          .addAttributes(string("service.name", "checkout"))
+                                          .addAttributes(string("service.instance.id", "checkout-1"))
+                                          .build();
+        final InstrumentationScope scope = InstrumentationScope.newBuilder().setName("io.opentelemetry.http").build();
+        final String tooLong = "x".repeat(Tag.TAG_LENGTH + 1);
+        final Span span = span().addAttributes(string("http.request.method", "GET"))
+                                .addAttributes(string("peer.service", "payment"))
+                                .addAttributes(KeyValue.newBuilder()
+                                                       .setKey("http.response.status_code")
+                                                       .setValue(AnyValue.newBuilder().setIntValue(200)))
+                                .addAttributes(string("db.statement", tooLong))
+                                .build();
+
+        assertTrue(forward.send(resource, "https://opentelemetry.io/schemas/1.26.0", scope, "", span, "checkout",
+                                Map.of("service.name", "checkout", "service.instance.id", "checkout-1"),
+                                Map.of("gen_ai.injected", "yes")
+        ));
+
+        final List<ISource> sources = received();
+        final OTLPSpan stored = single(sources, OTLPSpan.class);
+        assertEquals("0af7651916cd43dd0000000000000001", stored.getTraceId());
+        assertEquals("00b7ad6b71692033", stored.getSpanId());
+        assertEquals("0102030405060708", stored.getParentSpanId());
+        assertEquals("checkout", stored.getServiceName());
+        assertEquals("checkout-1", stored.getServiceInstance());
+        assertEquals("io.opentelemetry.http", stored.getScopeName());
+        assertEquals("GET /checkout", stored.getName());
+        assertEquals(Span.SpanKind.SPAN_KIND_SERVER.getNumber(), stored.getKind());
+        assertEquals(Status.StatusCode.STATUS_CODE_ERROR.getNumber(), stored.getStatusCode());
+        assertEquals("payment", stored.getPeerService());
+        assertEquals(START_NANOS / 1_000_000L, stored.getStartTime());
+        assertEquals(2_000_000L, stored.getDuration());
+        // resource attributes under `resource.`, span attributes under `span.`, so the scopes stay apart
+        assertTrue(stored.getTags().containsAll(Arrays.asList(
+            "resource.service.name=checkout", "resource.service.instance.id=checkout-1", "span.http.request.method=GET",
+            "span.peer.service=payment", "span.http.response.status_code=200", "span.gen_ai.injected=yes"
+        )));
+        assertFalse(stored.getTags().stream().anyMatch(tag -> tag.contains("db.statement=")));
+        assertFalse(stored.getTags().stream().anyMatch(tag -> tag.startsWith("service.name=")));
+
+        final ResourceSpans dataBinary = ResourceSpans.parseFrom(stored.getDataBinary());
+        assertEquals(resource, dataBinary.getResource());
+        assertEquals("https://opentelemetry.io/schemas/1.26.0", dataBinary.getSchemaUrl());
+        assertEquals(scope, dataBinary.getScopeSpans(0).getScope());
+        final Span storedSpan = dataBinary.getScopeSpans(0).getSpans(0);
+        // The injected attribute is the only difference from the wire span.
+        assertEquals(span, storedSpan.toBuilder().removeAttributes(storedSpan.getAttributesCount() - 1).build());
+        assertEquals("gen_ai.injected", storedSpan.getAttributes(storedSpan.getAttributesCount() - 1).getKey());
+
+        assertEquals("checkout", single(sources, OTLPService.class).getServiceName());
+        final OTLPServiceSpan serviceSpan = single(sources, OTLPServiceSpan.class);
+        assertEquals("checkout", serviceSpan.getServiceName());
+        assertEquals("GET /checkout", serviceSpan.getSpanName());
+        final OTLPServiceRelation relation = single(sources, OTLPServiceRelation.class);
+        assertEquals("checkout", relation.getServiceName());
+        assertEquals("payment", relation.getPeerService());
+
+        final Map<String, String> autocomplete = sources.stream()
+                                                        .filter(TagAutocomplete.class::isInstance)
+                                                        .map(TagAutocomplete.class::cast)
+                                                        .peek(tag -> assertEquals(TagType.OTLP, tag.getTagType()))
+                                                        .collect(Collectors.toMap(
+                                                            TagAutocomplete::getTagKey, TagAutocomplete::getTagValue));
+        assertEquals(Map.of(
+            "http.request.method", "GET",
+            "peer.service", "payment",
+            OTLPSpanForward.SCOPE_NAME_TAG, "io.opentelemetry.http",
+            OTLPSpanForward.INSTANCE_TAG, "checkout-1"
+        ), autocomplete);
+    }
+
+    @Test
+    void shouldDropSpansOverTheRateLimit() {
+        config.setOtlpTraceMaxSpansPerSecond(1);
+        final OTLPSpanForward forward = new OTLPSpanForward(config, moduleManager);
+
+        assertTrue(send(forward, TRACE_ID));
+        assertFalse(send(forward, TRACE_ID));
+    }
+
+    @Test
+    void shouldSampleOnTheLowBitsOfTheTraceId() {
+        config.setOtlpTraceSampleRate(5000);
+        final OTLPSpanForward forward = new OTLPSpanForward(config, moduleManager);
+        final byte[] highTraceId = TRACE_ID.clone();
+        highTraceId[8] = 0x7f;
+        highTraceId[9] = (byte) 0xff;
+
+        assertTrue(send(forward, TRACE_ID));
+        assertFalse(send(forward, highTraceId));
+    }
+
+    private boolean send(final OTLPSpanForward forward, final byte[] traceId) {
+        return forward.send(
+            Resource.getDefaultInstance(), "", InstrumentationScope.getDefaultInstance(), "",
+            span().setTraceId(ByteString.copyFrom(traceId)).build(), "checkout", Map.of(), Map.of()
+        );
+    }
+
+    private static Span.Builder span() {
+        return Span.newBuilder()
+                   .setTraceId(ByteString.copyFrom(TRACE_ID))
+                   .setSpanId(ByteString.copyFrom(SPAN_ID))
+                   .setParentSpanId(ByteString.copyFrom(PARENT_SPAN_ID))
+                   .setName("GET /checkout")
+                   .setKind(Span.SpanKind.SPAN_KIND_SERVER)
+                   .setStartTimeUnixNano(START_NANOS)
+                   .setEndTimeUnixNano(START_NANOS + 2_000_000L)
+                   .setStatus(Status.newBuilder().setCode(Status.StatusCode.STATUS_CODE_ERROR));
+    }
+
+    private static KeyValue string(final String key, final String value) {
+        return KeyValue.newBuilder().setKey(key).setValue(AnyValue.newBuilder().setStringValue(value)).build();
+    }
+
+    private List<ISource> received() {
+        final ArgumentCaptor<ISource> captor = ArgumentCaptor.forClass(ISource.class);
+        verify(receiver, atLeastOnce()).receive(captor.capture());
+        return captor.getAllValues();
+    }
+
+    private static <T> T single(final List<ISource> sources, final Class<T> type) {
+        final List<T> matches = sources.stream().filter(type::isInstance).map(type::cast).collect(Collectors.toList());
+        assertEquals(1, matches.size(), "expected exactly one " + type.getSimpleName());
+        return matches.get(0);
+    }
+}
diff --git a/oap-server/server-receiver-plugin/receiver-proto/pom.xml b/oap-server/server-receiver-plugin/receiver-proto/pom.xml
index fd83c06c87..5b5e458416 100644
--- a/oap-server/server-receiver-plugin/receiver-proto/pom.xml
+++ b/oap-server/server-receiver-plugin/receiver-proto/pom.xml
@@ -49,6 +49,11 @@
             <groupId>com.google.protobuf</groupId>
             <artifactId>protobuf-java-util</artifactId>
         </dependency>
+        <!-- OTLPValues renders attribute values for the tag index; shared by the OTLP receiver and the TraceQL plugin. -->
+        <dependency>
+            <groupId>com.google.code.gson</groupId>
+            <artifactId>gson</artifactId>
+        </dependency>
     </dependencies>
 
     <profiles>
diff --git a/oap-server/server-receiver-plugin/receiver-proto/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPValues.java b/oap-server/server-receiver-plugin/receiver-proto/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPValues.java
new file mode 100644
index 0000000000..7de09c4ca4
--- /dev/null
+++ b/oap-server/server-receiver-plugin/receiver-proto/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OTLPValues.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.receiver.otel.otlp;
+
+import com.google.gson.JsonObject;
+import com.google.protobuf.ByteString;
+import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * The one place OTLP attribute values and ids are rendered as strings. The Zipkin conversion, the native span
+ * index, the span listeners and the tag autocomplete all read the same spelling, so a value searched for on one
+ * path matches what another path stored.
+ * Lives beside the OTLP protos so the receiver, which writes the {@code key=value} index, and the TraceQL plugin,
+ * which re-renders attributes to match against it, spell values identically.
+ */
+public final class OTLPValues {
+    private OTLPValues() {
+    }
+
+    /**
+     * Render an attribute value as text: scalars as their text form, arrays joined by {@code ,}, key-value lists
+     * as a JSON object, bytes as base64. An unset value renders as the empty string.
+     */
+    public static String render(final AnyValue value) {
+        if (value == null) {
+            return "";
+        }
+        if (value.hasBoolValue()) {
+            return String.valueOf(value.getBoolValue());
+        } else if (value.hasDoubleValue()) {
+            return String.valueOf(value.getDoubleValue());
+        } else if (value.hasStringValue()) {
+            return value.getStringValue();
+        } else if (value.hasArrayValue()) {
+            return value.getArrayValue().getValuesList().stream().map(OTLPValues::render).collect(Collectors.joining(","));
+        } else if (value.hasIntValue()) {
+            return String.valueOf(value.getIntValue());
+        } else if (value.hasKvlistValue()) {
+            return renderKvList(value.getKvlistValue().getValuesList()).toString();
+        } else if (value.hasBytesValue()) {
+            return new String(Base64.getEncoder().encode(value.getBytesValue().toByteArray()), StandardCharsets.UTF_8);
+        }
+        return "";
+    }
+
+    public static JsonObject renderKvList(final List<KeyValue> keyValues) {
+        final JsonObject json = new JsonObject();
+        for (final KeyValue keyValue : keyValues) {
+            json.addProperty(keyValue.getKey(), render(keyValue.getValue()));
+        }
+        return json;
+    }
+
+    /**
+     * Attributes as a string map; the first occurrence of a duplicated key wins.
+     */
+    public static Map<String, String> toStringMap(final List<KeyValue> attributes) {
+        return attributes.stream().collect(Collectors.toMap(
+            KeyValue::getKey,
+            keyValue -> render(keyValue.getValue()),
+            (v1, v2) -> v1
+        ));
+    }
+
+    /**
+     * A trace, span or link id as lowercase hex; the empty string for a missing id.
+     */
+    public static String hexId(final ByteString id) {
+        if (id == null || id.isEmpty()) {
+            return "";
+        }
+        final char[] hex = new char[id.size() * 2];
+        for (int i = 0; i < id.size(); i++) {
+            final int value = id.byteAt(i) & 0xff;
+            hex[i * 2] = Character.forDigit(value >>> 4, 16);
+            hex[i * 2 + 1] = Character.forDigit(value & 0x0f, 16);
+        }
+        return new String(hex);
+    }
+}
diff --git a/oap-server/server-receiver-plugin/receiver-proto/src/main/proto/opentelemetry/proto/trace/v1/trace.proto b/oap-server/server-receiver-plugin/receiver-proto/src/main/proto/opentelemetry/proto/trace/v1/trace.proto
index 5903550742..0569e1b5f6 100644
--- a/oap-server/server-receiver-plugin/receiver-proto/src/main/proto/opentelemetry/proto/trace/v1/trace.proto
+++ b/oap-server/server-receiver-plugin/receiver-proto/src/main/proto/opentelemetry/proto/trace/v1/trace.proto
@@ -76,7 +76,7 @@ message ScopeSpans {
 
 // A Span represents a single operation performed by a single component of the system.
 //
-// The next available field id is 17.
+// The next available field id is 18.
 message Span {
   // A unique identifier for a trace. All spans from the same trace share
   // the same `trace_id`. The ID is a 16-byte array. An ID with all zeroes
@@ -240,6 +240,25 @@ message Span {
     // dropped_attributes_count is the number of dropped attributes. If the value is 0,
     // then no attributes were dropped.
     uint32 dropped_attributes_count = 5;
+
+    // Flags, a bit field.
+    //
+    // Bits 0-7 (8 least significant bits) are the trace flags as defined in W3C Trace
+    // Context specification. To read the 8-bit W3C trace flag, use
+    // `flags & SPAN_FLAGS_TRACE_FLAGS_MASK`.
+    //
+    // See https://www.w3.org/TR/trace-context-2/#trace-flags for the flag definitions.
+    //
+    // Bits 8 and 9 represent the 3 states of whether the link is remote.
+    // The states are (unknown, is not remote, is remote).
+    // To read whether the value is known, use `(flags & SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE_MASK) != 0`.
+    // To read whether the link is remote, use `(flags & SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK) != 0`.
+    //
+    // Readers MUST NOT assume that bits 10-31 (22 most significant bits) will be zero.
+    // When creating new spans, bits 10-31 (most-significant 22-bits) MUST be zero.
+    //
+    // [Optional].
+    fixed32 flags = 6;
   }
 
   // links is a collection of Links, which are references from this span to a span
@@ -253,6 +272,27 @@ message Span {
   // An optional final status for this span. Semantically when Status isn't set, it means
   // span's status code is unset, i.e. assume STATUS_CODE_UNSET (code = 0).
   Status status = 15;
+
+  // Flags, a bit field.
+  //
+  // Bits 0-7 (8 least significant bits) are the trace flags as defined in W3C Trace
+  // Context specification. To read the 8-bit W3C trace flag, use
+  // `flags & SPAN_FLAGS_TRACE_FLAGS_MASK`.
+  //
+  // See https://www.w3.org/TR/trace-context-2/#trace-flags for the flag definitions.
+  //
+  // Bits 8 and 9 represent the 3 states of whether a span's parent
+  // is remote. The states are (unknown, is not remote, is remote).
+  // To read whether the value is known, use `(flags & SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE_MASK) != 0`.
+  // To read whether the span is remote, use `(flags & SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK) != 0`.
+  //
+  // When creating span messages, if the message is logically forked from an existing
+  // span, the flags field MUST be set to the value of the existing span's flags.
+  // If a span is not forked, the flags field is 0 or the trace flags of the span's
+  // context, and bits 8 and 9 are set as described above.
+  //
+  // [Optional].
+  fixed32 flags = 16;
 }
 
 // The Status type defines a logical error model that is suitable for different
@@ -278,3 +318,34 @@ message Status {
   // The status code.
   StatusCode code = 3;
 }
+
+// SpanFlags represents constants used to interpret the
+// Span.flags field, which is protobuf 'fixed32' type and is to
+// be used as bit-fields. Each non-zero value defined in this enum is
+// a bit-mask.  To extract the bit-field, for example, use an
+// expression like:
+//
+//   (span.flags & SPAN_FLAGS_TRACE_FLAGS_MASK)
+//
+// See https://www.w3.org/TR/trace-context-2/#trace-flags for the flag definitions.
+//
+// Note that Span flags were introduced in version 1.1 of the
+// OpenTelemetry protocol.  Older Span producers do not set this
+// field, consequently consumers should not rely on the absence of a
+// particular flag bit to indicate the presence of a particular feature.
+enum SpanFlags {
+  // The zero value for the enum. Should not be used for comparisons.
+  // Instead use bitwise "and" with the appropriate mask as shown above.
+  SPAN_FLAGS_DO_NOT_USE = 0;
+
+  // Bits 0-7 are used for trace flags.
+  SPAN_FLAGS_TRACE_FLAGS_MASK = 0x000000FF;
+
+  // Bits 8 and 9 are used to indicate that the parent span or link span is remote.
+  // Bit 8 (`HAS_IS_REMOTE`) indicates whether the value is known.
+  // Bit 9 (`IS_REMOTE`) indicates whether the span or link is remote.
+  SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE_MASK = 0x00000100;
+  SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK = 0x00000200;
+
+  // Bits 10-31 are reserved for future use.
+}
diff --git a/oap-server/server-starter/src/main/resources/application.yml b/oap-server/server-starter/src/main/resources/application.yml
index 61589065fa..52c48c1361 100644
--- a/oap-server/server-starter/src/main/resources/application.yml
+++ b/oap-server/server-starter/src/main/resources/application.yml
@@ -433,6 +433,17 @@ receiver-otel:
   selector: ${SW_OTEL_RECEIVER:default}
   default:
     enabledHandlers: ${SW_OTEL_RECEIVER_ENABLED_HANDLERS:"otlp-traces,otlp-metrics,otlp-logs"}
+    # Where the `otlp-traces` handler stores spans.
+    #   otlp:   store the native OTLP span in `otlp_span`, served by the TraceQL `/otlp` datasource
+    #   zipkin: convert to Zipkin v2 and hand them to receiver-zipkin (needs receiver-zipkin and query-zipkin)
+    otlpTraceStorage: ${SW_OTEL_TRACE_STORAGE:otlp}
+    # `otlp` mode only. Attribute keys offered by tag autocomplete (TraceQL /api/v2/search/tags). Every resource and span
+    # attribute is indexed for equality search regardless of this list; same meaning as receiver-zipkin.searchableTracesTags.
+    otlpTraceSearchableTags: ${SW_OTEL_TRACE_SEARCHABLE_TAG_KEYS:http.request.method,http.response.status_code,url.path,rpc.method,rpc.grpc.status_code,db.system.name,db.namespace,messaging.system,messaging.destination.name,peer.service}
+    # `otlp` mode only. Head sampling by trace id, precision 1/10000.
+    otlpTraceSampleRate: ${SW_OTEL_TRACE_SAMPLE_RATE:10000}
+    # `otlp` mode only. Spans per second accepted before dropping. 0 means no limit.
+    otlpTraceMaxSpansPerSecond: ${SW_OTEL_TRACE_MAX_SPANS_PER_SECOND:0}
     enabledOtelMetricsRules: ${SW_OTEL_RECEIVER_ENABLED_OTEL_METRICS_RULES:"apisix,nginx/*,k8s/*,istio-controlplane,vm,mysql/*,postgresql/*,oap,aws-eks/*,windows,aws-s3/*,aws-dynamodb/*,aws-gateway/*,redis/*,elasticsearch/*,rabbitmq/*,mongodb/*,kafka/*,pulsar/*,bookkeeper/*,rocketmq/*,clickhouse/*,activemq/*,kong/*,flink/*,airflow/*,banyandb/*,envoy-ai-gateway/*,ios/*,miniprogram/*,ai-agent/*"}
 
 receiver-zipkin:
@@ -558,8 +569,11 @@ traceQL:
     restPort: ${SW_TRACEQL_REST_PORT:3200}
     enableDatasourceZipkin: ${SW_TRACEQL_ENABLE_DATASOURCE_ZIPKIN:false}
     enableDatasourceSkywalking: ${SW_TRACEQL_ENABLE_DATASOURCE_SKYWALKING:false}
+    # Natively stored OTLP spans (receiver-otel.otlpTraceStorage: otlp), served as the OTLP they arrived as.
+    enableDatasourceOTLP: ${SW_TRACEQL_ENABLE_DATASOURCE_OTLP:false}
     restContextPathZipkin: ${SW_TRACEQL_REST_CONTEXT_PATH_ZIPKIN:/zipkin}
     restContextPathSkywalking: ${SW_TRACEQL_REST_CONTEXT_PATH_SKYWALKING:/skywalking}
+    restContextPathOTLP: ${SW_TRACEQL_REST_CONTEXT_PATH_OTLP:/otlp}
     restIdleTimeOut: ${SW_TRACEQL_REST_IDLE_TIMEOUT:30000}
     restAcceptQueueSize: ${SW_TRACEQL_REST_QUEUE_SIZE:0}
     restSSLEnabled: ${SW_TRACEQL_REST_SSL_ENABLED:false}
@@ -570,6 +584,8 @@ traceQL:
     # The tags in the list result of traces query, which would be used for trace grouping and shown in the trace list page.
     zipkinTracesListResultTags: ${SW_TRACEQL_ZIPKIN_TRACES_LIST_RESULT_TAGS:http.method,error}
     skywalkingTracesListResultTags: ${SW_TRACEQL_SKYWALKING_TRACES_LIST_RESULT_TAGS:http.method,http.status_code,rpc.status_code,db.type,db.instance,mq.queue,mq.topic,mq.broker}
+    # The span attributes included in the trace list result of the OTLP datasource, used by Grafana for grouping.
+    otlpTracesListResultTags: ${SW_TRACEQL_OTLP_TRACES_LIST_RESULT_TAGS:http.request.method,http.response.status_code}
 
 alarm:
   selector: ${SW_ALARM:default}
diff --git a/oap-server/server-starter/src/main/resources/bydb.yml b/oap-server/server-starter/src/main/resources/bydb.yml
index 26a2d64276..bad8345025 100644
--- a/oap-server/server-starter/src/main/resources/bydb.yml
+++ b/oap-server/server-starter/src/main/resources/bydb.yml
@@ -209,6 +209,28 @@ groups:
             # include both bare keys and "key=value". Same compact grammar as the trace group:
             #   SW_STORAGE_BANYANDB_ZIPKIN_TRACE_SAMPLER_KEEP_TAG_RULES='http.status_code=~5\d\d'
             keepTagRules: ${SW_STORAGE_BANYANDB_ZIPKIN_TRACE_SAMPLER_KEEP_TAG_RULES:[]}
+  # Natively stored OTLP spans (`otlp_span`, CATALOG_TRACE), written when `receiver-otel.otlpTraceStorage` is `otlp`.
+  # Kept apart from `zipkinTrace` so OTLP and Zipkin traces can be retained and sized independently. BanyanDB ships no
+  # `otlp-trace-sampler` plugin yet, so this group takes no `pipeline` block until one lands.
+  otlpTrace:
+    shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_SHARD_NUM:2}
+    segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_SI_DAYS:1}
+    ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_TTL_DAYS:3}
+    replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_REPLICAS:0}
+    enableWarmStage: ${SW_STORAGE_BANYANDB_OTLP_TRACE_ENABLE_WARM_STAGE:false}
+    enableColdStage: ${SW_STORAGE_BANYANDB_OTLP_TRACE_ENABLE_COLD_STAGE:false}
+    warm:
+      shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_SHARD_NUM:2}
+      segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_SI_DAYS:1}
+      ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_TTL_DAYS:7}
+      replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_REPLICAS:0}
+      nodeSelector: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_NODE_SELECTOR:"type=warm"}
+    cold:
+      shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_SHARD_NUM:2}
+      segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_SI_DAYS:1}
+      ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_TTL_DAYS:30}
+      replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_REPLICAS:0}
+      nodeSelector: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_NODE_SELECTOR:"type=cold"}
   recordsLog:
     shardNum: ${SW_STORAGE_BANYANDB_LOG_SHARD_NUM:2}
     segmentInterval: ${SW_STORAGE_BANYANDB_LOG_SI_DAYS:1}
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigDumpExtension.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigDumpExtension.java
index f08a1e962c..a79cf35b77 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigDumpExtension.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigDumpExtension.java
@@ -62,6 +62,7 @@ public class BanyanDBConfigDumpExtension implements ConfigDumpExtension {
         groups.put("recordsLog", config.getRecordsLog());
         groups.put("trace", config.getTrace());
         groups.put("zipkinTrace", config.getZipkinTrace());
+        groups.put("otlpTrace", config.getOtlpTrace());
         groups.put("recordsBrowserErrorLog", config.getRecordsBrowserErrorLog());
         groups.put("recordsAIAgent", config.getRecordsAIAgent());
         groups.put("metricsMinute", config.getMetricsMin());
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigLoader.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigLoader.java
index ba97928576..946139719e 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigLoader.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigLoader.java
@@ -121,6 +121,17 @@ public class BanyanDBConfigLoader {
                     (Map<String, Object>) rawGroups.get(BanyanDB.TraceGroup.ZIPKIN_TRACE.getName()), config.getZipkinTrace());
             }
 
+            Properties otlpSpan = (Properties) groups.get(BanyanDB.TraceGroup.OTLP_TRACE.getName());
+            copyProperties(
+                config.getOtlpTrace(), otlpSpan,
+                moduleProvider.getModule().name(), moduleProvider.name()
+            );
+            copyStages(otlpSpan, config.getOtlpTrace());
+            if (rawGroups != null && rawGroups.get(BanyanDB.TraceGroup.OTLP_TRACE.getName()) instanceof Map) {
+                copyPipeline(
+                    (Map<String, Object>) rawGroups.get(BanyanDB.TraceGroup.OTLP_TRACE.getName()), config.getOtlpTrace());
+            }
+
             Properties aiAgent = (Properties) groups.get(BanyanDB.StreamGroup.RECORDS_AI_AGENT.getName());
             copyProperties(
                     config.getRecordsAIAgent(), aiAgent,
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageConfig.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageConfig.java
index 6329fb5164..cfa888831f 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageConfig.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageConfig.java
@@ -40,6 +40,7 @@ public class BanyanDBStorageConfig extends ModuleConfig {
     private RecordsNormal recordsNormal = new RecordsNormal();
     private Trace trace = new Trace();
     private ZipkinTrace zipkinTrace = new ZipkinTrace();
+    private OtlpTrace otlpTrace = new OtlpTrace();
     private RecordsTrace recordsTrace = new RecordsTrace();
     private RecordsZipkinTrace recordsZipkinTrace = new RecordsZipkinTrace();
     private RecordsLog recordsLog = new RecordsLog();
@@ -241,6 +242,15 @@ public class BanyanDBStorageConfig extends ModuleConfig {
     public static class ZipkinTrace extends BanyanDBStorageConfig.GroupResource {
     }
 
+    /**
+     * The group of natively stored OTLP spans, {@code otlp_span}, kept apart from {@link ZipkinTrace} so the two
+     * protocols can be retained and sized on their own.
+     */
+    @Getter
+    @Setter
+    public static class OtlpTrace extends BanyanDBStorageConfig.GroupResource {
+    }
+
     @Getter
     @Setter
     public static class RecordsTrace extends BanyanDBStorageConfig.GroupResource {
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageProvider.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageProvider.java
index a0ff30d9f6..767968862b 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageProvider.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageProvider.java
@@ -72,6 +72,7 @@ import org.apache.skywalking.oap.server.core.storage.query.ISpanAttachedEventQue
 import org.apache.skywalking.oap.server.core.storage.query.ITagAutoCompleteQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.ITopologyQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.ITraceQueryDAO;
+import org.apache.skywalking.oap.server.core.storage.query.IOTLPTraceQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.IZipkinQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.ttl.StorageTTLStatusQuery;
 import org.apache.skywalking.oap.server.library.module.ModuleDefine;
@@ -111,6 +112,7 @@ import org.apache.skywalking.oap.server.storage.plugin.banyandb.stream.BanyanDBP
 import org.apache.skywalking.oap.server.storage.plugin.banyandb.stream.BanyanDBSpanAttachedEventQueryDAO;
 import org.apache.skywalking.oap.server.storage.plugin.banyandb.stream.BanyanDBStorageDAO;
 import org.apache.skywalking.oap.server.storage.plugin.banyandb.trace.BanyanDBTraceQueryDAO;
+import org.apache.skywalking.oap.server.storage.plugin.banyandb.trace.BanyanDBOTLPTraceQueryDAO;
 import org.apache.skywalking.oap.server.storage.plugin.banyandb.trace.BanyanDBZipkinQueryDAO;
 import org.apache.skywalking.oap.server.telemetry.TelemetryModule;
 import org.apache.skywalking.oap.server.telemetry.api.HealthCheckMetrics;
@@ -225,6 +227,7 @@ public class BanyanDBStorageProvider extends ModuleProvider {
         this.registerServiceImplementation(IAggregationQueryDAO.class, new BanyanDBAggregationQueryDAO(client));
         this.registerServiceImplementation(IRecordsQueryDAO.class, new BanyanDBRecordsQueryDAO(client));
         this.registerServiceImplementation(IZipkinQueryDAO.class, new BanyanDBZipkinQueryDAO(client));
+        this.registerServiceImplementation(IOTLPTraceQueryDAO.class, new BanyanDBOTLPTraceQueryDAO(client));
         this.registerServiceImplementation(ISpanAttachedEventQueryDAO.class, new BanyanDBSpanAttachedEventQueryDAO(client, this.config.getGlobal().getProfileDataQueryBatchSize()));
         this.registerServiceImplementation(IHierarchyQueryDAO.class, new BanyanDBHierarchyQueryDAO(client, this.config));
         this.registerServiceImplementation(
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBTTLStatusQuery.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBTTLStatusQuery.java
index 77029823b5..97053934c9 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBTTLStatusQuery.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBTTLStatusQuery.java
@@ -29,6 +29,7 @@ public class BanyanDBTTLStatusQuery implements StorageTTLStatusQuery {
     private int grNormalTTLDays;
     private int grTraceTTLDays;
     private int grZipkinTraceTTLDays;
+    private int grOtlpTraceTTLDays;
     private int grLogTTLDays;
     private int grBrowserErrorLogTTLDays;
     private int grSuperTTLDays;
@@ -36,6 +37,7 @@ public class BanyanDBTTLStatusQuery implements StorageTTLStatusQuery {
     private int grColdNormalTTLDays = -1;
     private int grColdTraceTTLDays = -1;
     private int grColdZipkinTraceTTLDays = -1;
+    private int grColdOtlpTraceTTLDays = -1;
     private int grColdLogTTLDays = -1;
     private int grColdBrowserErrorLogTTLDays = -1;
     private int gmMinuteTTLDays;
@@ -50,6 +52,7 @@ public class BanyanDBTTLStatusQuery implements StorageTTLStatusQuery {
         grNormalTTLDays = config.getRecordsNormal().getTtl();
         grTraceTTLDays = config.getTrace().getTtl();
         grZipkinTraceTTLDays = config.getZipkinTrace().getTtl();
+        grOtlpTraceTTLDays = config.getOtlpTrace().getTtl();
         grLogTTLDays = config.getRecordsLog().getTtl();
         grBrowserErrorLogTTLDays = config.getRecordsBrowserErrorLog().getTtl();
         gmMinuteTTLDays = config.getMetricsMin().getTtl();
@@ -78,6 +81,13 @@ public class BanyanDBTTLStatusQuery implements StorageTTLStatusQuery {
                 grColdZipkinTraceTTLDays = stage.getTtl();
             }
         });
+        config.getOtlpTrace().getAdditionalLifecycleStages().forEach(stage -> {
+            if (stage.getName().equals(BanyanDBStorageConfig.StageName.warm)) {
+                grOtlpTraceTTLDays = grOtlpTraceTTLDays + stage.getTtl();
+            } else if (stage.getName().equals(BanyanDBStorageConfig.StageName.cold)) {
+                grColdOtlpTraceTTLDays = stage.getTtl();
+            }
+        });
         config.getRecordsLog().getAdditionalLifecycleStages().forEach(stage -> {
             if (stage.getName().equals(BanyanDBStorageConfig.StageName.warm)) {
                 grLogTTLDays = grLogTTLDays + stage.getTtl();
@@ -119,11 +129,15 @@ public class BanyanDBTTLStatusQuery implements StorageTTLStatusQuery {
     public TTLDefinition getTTL() {
         TTLDefinition definition = new TTLDefinition(
             new MetricsTTL(gmMetadataTTLDays, gmMinuteTTLDays, gmHourTTLDays, gmDayTTLDays),
-            new RecordsTTL(grNormalTTLDays, grTraceTTLDays, grZipkinTraceTTLDays, grLogTTLDays, grBrowserErrorLogTTLDays)
+            new RecordsTTL(
+                grNormalTTLDays, grTraceTTLDays, grZipkinTraceTTLDays, grOtlpTraceTTLDays, grLogTTLDays,
+                grBrowserErrorLogTTLDays
+            )
         );
         definition.getRecords().setColdNormal(grColdNormalTTLDays);
         definition.getRecords().setColdTrace(grColdTraceTTLDays);
         definition.getRecords().setColdZipkinTrace(grColdZipkinTraceTTLDays);
+        definition.getRecords().setColdOtlpTrace(grColdOtlpTraceTTLDays);
         definition.getRecords().setColdLog(grColdLogTTLDays);
         definition.getRecords().setColdBrowserErrorLog(grColdBrowserErrorLogTTLDays);
         definition.getMetrics().setColdMinute(gmColdMinuteTTLDays);
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
index fa9221fffd..eb897545dd 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
@@ -840,6 +840,15 @@ public enum MetadataRegistry {
                             DownSampling.None,
                             config.getZipkinTrace()
                         );
+                    case OTLP_TRACE:
+                        return new SchemaMetadata(
+                            namespace,
+                            BanyanDB.TraceGroup.OTLP_TRACE.getName(),
+                            model.getName(),
+                            Kind.TRACE,
+                            DownSampling.None,
+                            config.getOtlpTrace()
+                        );
                     default:
                         throw new IllegalStateException("unknown trace group " + traceGroup);
                 }
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/trace/BanyanDBOTLPTraceQueryDAO.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/trace/BanyanDBOTLPTraceQueryDAO.java
new file mode 100644
index 0000000000..4a351d11e0
--- /dev/null
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/trace/BanyanDBOTLPTraceQueryDAO.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.storage.plugin.banyandb.trace;
+
+import com.google.common.collect.ImmutableSet;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.skywalking.banyandb.trace.v1.BanyandbTrace;
+import org.apache.skywalking.library.banyandb.v1.client.DataPoint;
+import org.apache.skywalking.library.banyandb.v1.client.MeasureQueryResponse;
+import org.apache.skywalking.library.banyandb.v1.client.TraceQueryResponse;
+import org.apache.skywalking.oap.server.core.analysis.DownSampling;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceRelationTraffic;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceSpanTraffic;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceTraffic;
+import org.apache.skywalking.oap.server.core.otlp.OTLPSpanRecord;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.input.OTLPTraceQueryCondition;
+import org.apache.skywalking.oap.server.core.query.type.QueryOrder;
+import org.apache.skywalking.oap.server.core.storage.query.IOTLPTraceQueryDAO;
+import org.apache.skywalking.oap.server.core.storage.query.proto.SpanWrapper;
+import org.apache.skywalking.oap.server.library.util.CollectionUtils;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+import org.apache.skywalking.oap.server.storage.plugin.banyandb.BanyanDBStorageClient;
+import org.apache.skywalking.oap.server.storage.plugin.banyandb.MetadataRegistry;
+import org.apache.skywalking.oap.server.storage.plugin.banyandb.stream.AbstractBanyanDBDAO;
+import org.apache.skywalking.oap.server.storage.plugin.banyandb.stream.Conditions;
+
+/**
+ * Reads {@code otlp_span} through the BanyanDB trace model, which returns the spans of each matched trace already
+ * grouped, and the three name catalogs through their index-mode measures.
+ */
+public class BanyanDBOTLPTraceQueryDAO extends AbstractBanyanDBDAO implements IOTLPTraceQueryDAO {
+    private static final int QUERY_MAX_SIZE = Integer.MAX_VALUE;
+    private static final Set<String> SERVICE_TRAFFIC_TAGS = ImmutableSet.of(OTLPServiceTraffic.SERVICE_NAME);
+    private static final Set<String> SPAN_TRAFFIC_TAGS = ImmutableSet.of(OTLPServiceSpanTraffic.SPAN_NAME);
+    private static final Set<String> RELATION_TRAFFIC_TAGS = ImmutableSet.of(OTLPServiceRelationTraffic.PEER_SERVICE);
+
+    public BanyanDBOTLPTraceQueryDAO(final BanyanDBStorageClient client) {
+        super(client);
+    }
+
+    @Override
+    public List<String> getServiceNames(@Nullable final Duration duration) throws IOException {
+        final MetadataRegistry.Schema schema = MetadataRegistry.INSTANCE.findMetricMetadata(
+            OTLPServiceTraffic.INDEX_NAME, DownSampling.Minute);
+        final MeasureQueryResponse resp = queryDebuggable(
+            false, schema, SERVICE_TRAFFIC_TAGS, Collections.emptySet(),
+            getTimestampRange(duration),
+            Conditions.create().limit(QUERY_MAX_SIZE));
+        final List<String> services = new ArrayList<>();
+        for (final DataPoint dataPoint : resp.getDataPoints()) {
+            services.add(dataPoint.getTagValue(OTLPServiceTraffic.SERVICE_NAME));
+        }
+        return services;
+    }
+
+    @Override
+    public List<String> getSpanNames(final String serviceName, @Nullable final Duration duration) throws IOException {
+        final MetadataRegistry.Schema schema = MetadataRegistry.INSTANCE.findMetricMetadata(
+            OTLPServiceSpanTraffic.INDEX_NAME, DownSampling.Minute);
+        final Conditions where = Conditions.create();
+        if (StringUtil.isNotEmpty(serviceName)) {
+            where.eq(OTLPServiceSpanTraffic.SERVICE_NAME, serviceName);
+        }
+        where.limit(QUERY_MAX_SIZE);
+        final MeasureQueryResponse resp = queryDebuggable(
+            false, schema, SPAN_TRAFFIC_TAGS, Collections.emptySet(), getTimestampRange(duration), where);
+        final List<String> spanNames = new ArrayList<>();
+        for (final DataPoint dataPoint : resp.getDataPoints()) {
+            spanNames.add(dataPoint.getTagValue(OTLPServiceSpanTraffic.SPAN_NAME));
+        }
+        return spanNames;
+    }
+
+    @Override
+    public List<String> getPeerServiceNames(final String serviceName,
+                                            @Nullable final Duration duration) throws IOException {
+        final MetadataRegistry.Schema schema = MetadataRegistry.INSTANCE.findMetricMetadata(
+            OTLPServiceRelationTraffic.INDEX_NAME, DownSampling.Minute);
+        final Conditions where = Conditions.create();
+        if (StringUtil.isNotEmpty(serviceName)) {
+            where.eq(OTLPServiceRelationTraffic.SERVICE_NAME, serviceName);
+        }
+        where.limit(QUERY_MAX_SIZE);
+        final MeasureQueryResponse resp = queryDebuggable(
+            false, schema, RELATION_TRAFFIC_TAGS, Collections.emptySet(), getTimestampRange(duration), where);
+        final List<String> peerServices = new ArrayList<>();
+        for (final DataPoint dataPoint : resp.getDataPoints()) {
+            peerServices.add(dataPoint.getTagValue(OTLPServiceRelationTraffic.PEER_SERVICE));
+        }
+        return peerServices;
+    }
+
+    @Override
+    public List<SpanWrapper> queryTraceById(final String traceId, @Nullable final Duration duration) throws IOException {
+        final boolean isColdStage = duration != null && duration.isColdStage();
+        final Conditions where = Conditions.create()
+                                           .eq(OTLPSpanRecord.TRACE_ID, traceId)
+                                           .limit(QUERY_MAX_SIZE);
+        final TraceQueryResponse resp = queryTraceDebuggable(
+            isColdStage, OTLPSpanRecord.INDEX_NAME, getTimestampRange(duration), where);
+        if (resp.getTraces().isEmpty()) {
+            return new ArrayList<>();
+        }
+        if (resp.getTraces().size() > 1) {
+            throw new IOException("More than one trace returned for traceId: " + traceId);
+        }
+        return unwrap(resp.getTraces().get(0).getSpansList());
+    }
+
+    @Override
+    public List<List<SpanWrapper>> queryTraces(final OTLPTraceQueryCondition condition) throws IOException {
+        final Duration duration = condition.getQueryDuration();
+        final boolean isColdStage = duration != null && duration.isColdStage();
+        final Conditions where = Conditions.create();
+        if (StringUtil.isNotEmpty(condition.getTraceId())) {
+            where.eq(OTLPSpanRecord.TRACE_ID, condition.getTraceId());
+        }
+        if (StringUtil.isNotEmpty(condition.getServiceName())) {
+            where.eq(OTLPSpanRecord.SERVICE_NAME, condition.getServiceName());
+        }
+        if (StringUtil.isNotEmpty(condition.getServiceInstance())) {
+            where.eq(OTLPSpanRecord.SERVICE_INSTANCE, condition.getServiceInstance());
+        }
+        if (StringUtil.isNotEmpty(condition.getScopeName())) {
+            where.eq(OTLPSpanRecord.SCOPE_NAME, condition.getScopeName());
+        }
+        if (StringUtil.isNotEmpty(condition.getSpanName())) {
+            where.eq(OTLPSpanRecord.NAME, condition.getSpanName());
+        }
+        if (StringUtil.isNotEmpty(condition.getPeerService())) {
+            where.eq(OTLPSpanRecord.PEER_SERVICE, condition.getPeerService());
+        }
+        if (condition.getKind() != null) {
+            where.eq(OTLPSpanRecord.KIND, condition.getKind().longValue());
+        }
+        if (condition.getStatusCode() != null) {
+            where.eq(OTLPSpanRecord.STATUS_CODE, condition.getStatusCode().longValue());
+        }
+        if (condition.getMinDurationNanos() != null) {
+            where.gte(OTLPSpanRecord.DURATION, condition.getMinDurationNanos());
+        }
+        if (condition.getMaxDurationNanos() != null) {
+            where.lte(OTLPSpanRecord.DURATION, condition.getMaxDurationNanos());
+        }
+        if (CollectionUtils.isNotEmpty(condition.getTags())) {
+            // Scoped keys must all be present (HAVING); an unscoped key is either of its two forms, an OR of two
+            // HAVING groups, since the one-form-of-each semantics is exactly what TraceQL defines for `.key`.
+            final List<String> scoped = new ArrayList<>(condition.getTags().size());
+            for (final Tag tag : condition.getTags()) {
+                final List<String> forms = OTLPSpanRecord.indexedTags(tag.getKey(), tag.getValue());
+                if (forms.size() == 1) {
+                    scoped.add(forms.get(0));
+                } else {
+                    final List<Conditions> either = new ArrayList<>(forms.size());
+                    for (final String form : forms) {
+                        either.add(Conditions.group().having(OTLPSpanRecord.TAGS, List.of(form)));
+                    }
+                    where.or(either);
+                }
+            }
+            if (!scoped.isEmpty()) {
+                where.having(OTLPSpanRecord.TAGS, scoped);
+            }
+        }
+        if (condition.getQueryOrder() == QueryOrder.BY_DURATION) {
+            where.orderByDesc(OTLPSpanRecord.DURATION);
+        } else {
+            where.orderByDesc(OTLPSpanRecord.START_TIME);
+        }
+        where.limit(condition.getLimit());
+
+        final TraceQueryResponse resp = queryTraceDebuggable(
+            isColdStage, OTLPSpanRecord.INDEX_NAME, getTimestampRange(duration), where);
+        final List<List<SpanWrapper>> traces = new ArrayList<>(resp.getTraces().size());
+        for (final var trace : resp.getTraces()) {
+            traces.add(unwrap(trace.getSpansList()));
+        }
+        return traces;
+    }
+
+    private static List<SpanWrapper> unwrap(final List<BanyandbTrace.Span> spans) throws IOException {
+        final List<SpanWrapper> trace = new ArrayList<>(spans.size());
+        for (final var span : spans) {
+            trace.add(SpanWrapper.parseFrom(span.getSpan()));
+        }
+        return trace;
+    }
+}
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/test/resources/bydb.yml b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/test/resources/bydb.yml
index 7032cb63f1..362d698d10 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/test/resources/bydb.yml
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/test/resources/bydb.yml
@@ -175,6 +175,28 @@ groups:
                 regex: "http\\.status_code=5\\d\\d"
               - tagKey: local_endpoint_service_name
                 equals: gateway.sample-services
+  # Natively stored OTLP spans (`otlp_span`, CATALOG_TRACE), written when `receiver-otel.otlpTraceStorage` is `otlp`.
+  # Kept apart from `zipkinTrace` so OTLP and Zipkin traces can be retained and sized independently. BanyanDB ships no
+  # `otlp-trace-sampler` plugin yet, so this group takes no `pipeline` block until one lands.
+  otlpTrace:
+    shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_SHARD_NUM:2}
+    segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_SI_DAYS:1}
+    ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_TTL_DAYS:3}
+    replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_REPLICAS:0}
+    enableWarmStage: ${SW_STORAGE_BANYANDB_OTLP_TRACE_ENABLE_WARM_STAGE:false}
+    enableColdStage: ${SW_STORAGE_BANYANDB_OTLP_TRACE_ENABLE_COLD_STAGE:false}
+    warm:
+      shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_SHARD_NUM:2}
+      segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_SI_DAYS:1}
+      ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_TTL_DAYS:7}
+      replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_REPLICAS:0}
+      nodeSelector: ${SW_STORAGE_BANYANDB_OTLP_TRACE_WARM_NODE_SELECTOR:"type=warm"}
+    cold:
+      shardNum: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_SHARD_NUM:2}
+      segmentInterval: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_SI_DAYS:1}
+      ttl: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_TTL_DAYS:30}
+      replicas: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_REPLICAS:0}
+      nodeSelector: ${SW_STORAGE_BANYANDB_OTLP_TRACE_COLD_NODE_SELECTOR:"type=cold"}
   recordsLog:
     shardNum: ${SW_STORAGE_BANYANDB_LOG_SHARD_NUM:2}
     segmentInterval: ${SW_STORAGE_BANYANDB_LOG_SI_DAYS:1}
diff --git a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/StorageModuleElasticsearchProvider.java b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/StorageModuleElasticsearchProvider.java
index dffd372118..88a955490b 100644
--- a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/StorageModuleElasticsearchProvider.java
+++ b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/StorageModuleElasticsearchProvider.java
@@ -66,6 +66,7 @@ import org.apache.skywalking.oap.server.core.storage.query.ISpanAttachedEventQue
 import org.apache.skywalking.oap.server.core.storage.query.ITagAutoCompleteQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.ITopologyQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.ITraceQueryDAO;
+import org.apache.skywalking.oap.server.core.storage.query.IOTLPTraceQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.query.IZipkinQueryDAO;
 import org.apache.skywalking.oap.server.core.storage.ttl.DefaultStorageTTLStatusQuery;
 import org.apache.skywalking.oap.server.core.storage.ttl.StorageTTLStatusQuery;
@@ -114,6 +115,7 @@ import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.Topol
 import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.TraceQueryEsDAO;
 import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.RuntimeRuleManagementEsDAO;
 import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.UITemplateManagementEsDAO;
+import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.otlp.OTLPTraceQueryEsDAO;
 import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.zipkin.ZipkinQueryEsDAO;
 import org.apache.skywalking.oap.server.telemetry.TelemetryModule;
 import org.apache.skywalking.oap.server.telemetry.api.HealthCheckMetrics;
@@ -289,6 +291,8 @@ public class StorageModuleElasticsearchProvider extends ModuleProvider {
             ITagAutoCompleteQueryDAO.class, new TagAutoCompleteQueryDAO(elasticSearchClient));
         this.registerServiceImplementation(
             IZipkinQueryDAO.class, new ZipkinQueryEsDAO(elasticSearchClient));
+        this.registerServiceImplementation(
+            IOTLPTraceQueryDAO.class, new OTLPTraceQueryEsDAO(elasticSearchClient));
         this.registerServiceImplementation(
             ISpanAttachedEventQueryDAO.class, new SpanAttachedEventEsDAO(elasticSearchClient, config));
 
diff --git a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/otlp/OTLPTraceQueryEsDAO.java b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/otlp/OTLPTraceQueryEsDAO.java
new file mode 100644
index 0000000000..32e34c9538
--- /dev/null
+++ b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/otlp/OTLPTraceQueryEsDAO.java
@@ -0,0 +1,297 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.otlp;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.skywalking.library.elasticsearch.requests.search.BoolQueryBuilder;
+import org.apache.skywalking.library.elasticsearch.requests.search.Query;
+import org.apache.skywalking.library.elasticsearch.requests.search.Search;
+import org.apache.skywalking.library.elasticsearch.requests.search.SearchBuilder;
+import org.apache.skywalking.library.elasticsearch.requests.search.SearchParams;
+import org.apache.skywalking.library.elasticsearch.requests.search.Sort;
+import org.apache.skywalking.library.elasticsearch.requests.search.aggregation.Aggregation;
+import org.apache.skywalking.library.elasticsearch.requests.search.aggregation.BucketOrder;
+import org.apache.skywalking.library.elasticsearch.requests.search.aggregation.TermsAggregationBuilder;
+import org.apache.skywalking.library.elasticsearch.response.search.SearchHit;
+import org.apache.skywalking.library.elasticsearch.response.search.SearchResponse;
+import org.apache.skywalking.oap.server.core.analysis.TimeBucket;
+import org.apache.skywalking.oap.server.core.analysis.manual.searchtag.Tag;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceRelationTraffic;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceSpanTraffic;
+import org.apache.skywalking.oap.server.core.otlp.OTLPServiceTraffic;
+import org.apache.skywalking.oap.server.core.otlp.OTLPSpanRecord;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.input.OTLPTraceQueryCondition;
+import org.apache.skywalking.oap.server.core.query.type.QueryOrder;
+import org.apache.skywalking.oap.server.core.storage.query.IOTLPTraceQueryDAO;
+import org.apache.skywalking.oap.server.core.storage.query.proto.SpanWrapper;
+import org.apache.skywalking.oap.server.library.client.elasticsearch.ElasticSearchClient;
+import org.apache.skywalking.oap.server.library.client.elasticsearch.ElasticSearchScroller;
+import org.apache.skywalking.oap.server.library.util.CollectionUtils;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.base.ElasticSearchConverter;
+import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.base.EsDAO;
+import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.base.IndexController;
+import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.base.RoutingUtils;
+import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.base.TimeRangeIndexNameGenerator;
+
+/**
+ * Reads {@code otlp_span} on Elasticsearch: a terms aggregation on {@code trace_id} selects the traces, then the
+ * spans of the selected traces are fetched and grouped by trace, the shape {@code ZipkinQueryEsDAO} uses. The
+ * catalogs ignore the time range, as the Zipkin catalogs do on this storage.
+ */
+public class OTLPTraceQueryEsDAO extends EsDAO implements IOTLPTraceQueryDAO {
+    private static final int NAME_QUERY_MAX_SIZE = 10000;
+    private static final int SCROLLING_BATCH_SIZE = 5000;
+
+    public OTLPTraceQueryEsDAO(final ElasticSearchClient client) {
+        super(client);
+    }
+
+    @Override
+    public List<String> getServiceNames(@Nullable final Duration duration) {
+        final String index = IndexController.LogicIndicesRegister.getPhysicalTableName(OTLPServiceTraffic.INDEX_NAME);
+        final BoolQueryBuilder query = Query.bool();
+        if (IndexController.LogicIndicesRegister.isMergedTable(OTLPServiceTraffic.INDEX_NAME)) {
+            query.must(Query.term(IndexController.LogicIndicesRegister.METRIC_TABLE_NAME, OTLPServiceTraffic.INDEX_NAME));
+        }
+        final SearchBuilder search = Search.builder().query(query).size(SCROLLING_BATCH_SIZE);
+        final var scroller = ElasticSearchScroller
+            .<String>builder()
+            .client(getClient())
+            .search(search.build())
+            .index(index)
+            .resultConverter(hit -> new OTLPServiceTraffic.Builder().storage2Entity(
+                new ElasticSearchConverter.ToEntity(OTLPServiceTraffic.INDEX_NAME, hit.getSource())).getServiceName())
+            .build();
+        return scroller.scroll();
+    }
+
+    @Override
+    public List<String> getSpanNames(final String serviceName, @Nullable final Duration duration) {
+        final String index = IndexController.LogicIndicesRegister.getPhysicalTableName(OTLPServiceSpanTraffic.INDEX_NAME);
+        final BoolQueryBuilder query = Query.bool().must(Query.term(OTLPServiceSpanTraffic.SERVICE_NAME, serviceName));
+        if (IndexController.LogicIndicesRegister.isMergedTable(OTLPServiceSpanTraffic.INDEX_NAME)) {
+            query.must(Query.term(IndexController.LogicIndicesRegister.METRIC_TABLE_NAME, OTLPServiceSpanTraffic.INDEX_NAME));
+        }
+        final SearchBuilder search = Search.builder().query(query).size(NAME_QUERY_MAX_SIZE);
+        final SearchResponse response = getClient().search(index, search.build());
+        final List<String> spanNames = new ArrayList<>();
+        for (final SearchHit searchHit : response.getHits()) {
+            spanNames.add(new OTLPServiceSpanTraffic.Builder().storage2Entity(
+                new ElasticSearchConverter.ToEntity(OTLPServiceSpanTraffic.INDEX_NAME, searchHit.getSource())).getSpanName());
+        }
+        return spanNames;
+    }
+
+    @Override
+    public List<String> getPeerServiceNames(final String serviceName, @Nullable final Duration duration) {
+        final String index = IndexController.LogicIndicesRegister.getPhysicalTableName(OTLPServiceRelationTraffic.INDEX_NAME);
+        final BoolQueryBuilder query = Query.bool().must(Query.term(OTLPServiceRelationTraffic.SERVICE_NAME, serviceName));
+        if (IndexController.LogicIndicesRegister.isMergedTable(OTLPServiceRelationTraffic.INDEX_NAME)) {
+            query.must(Query.term(IndexController.LogicIndicesRegister.METRIC_TABLE_NAME, OTLPServiceRelationTraffic.INDEX_NAME));
+        }
+        final SearchBuilder search = Search.builder().query(query).size(NAME_QUERY_MAX_SIZE);
+        final SearchResponse response = getClient().search(index, search.build());
+        final List<String> peerServices = new ArrayList<>();
+        for (final SearchHit searchHit : response.getHits()) {
+            peerServices.add(new OTLPServiceRelationTraffic.Builder().storage2Entity(
+                new ElasticSearchConverter.ToEntity(OTLPServiceRelationTraffic.INDEX_NAME, searchHit.getSource())).getPeerService());
+        }
+        return peerServices;
+    }
+
+    @Override
+    public List<SpanWrapper> queryTraceById(final String traceId, @Nullable final Duration duration) {
+        final String physicalIndex = IndexController.LogicIndicesRegister.getPhysicalTableName(OTLPSpanRecord.INDEX_NAME);
+        final BoolQueryBuilder query = Query.bool().must(Query.term(OTLPSpanRecord.TRACE_ID, traceId));
+        final String index;
+        if (duration != null && duration.getStartTimestamp() > 0 && duration.getEndTimestamp() > 0) {
+            query.must(Query.range(OTLPSpanRecord.START_TIME)
+                            .gte(duration.getStartTimestamp())
+                            .lte(duration.getEndTimestamp()));
+            // One comma-separated index expression: the scroller takes a single index name.
+            index = String.join(",", new TimeRangeIndexNameGenerator(
+                physicalIndex,
+                TimeBucket.getRecordTimeBucket(duration.getStartTimestamp()),
+                TimeBucket.getRecordTimeBucket(duration.getEndTimestamp())
+            ).get());
+        } else {
+            index = physicalIndex;
+        }
+        final SearchBuilder search = Search.builder().query(query).size(SCROLLING_BATCH_SIZE);
+        final SearchParams params = new SearchParams();
+        RoutingUtils.addRoutingValueToSearchParam(params, traceId);
+        final var scroller = ElasticSearchScroller
+            .<SpanWrapper>builder()
+            .client(getClient())
+            .search(search.build())
+            .index(index)
+            .params(params)
+            .resultConverter(searchHit -> toSpanWrapper(searchHit.getSource()))
+            .build();
+        return scrollDebuggable(scroller, index, params);
+    }
+
+    @Override
+    public List<List<SpanWrapper>> queryTraces(final OTLPTraceQueryCondition condition) {
+        final Duration duration = condition.getQueryDuration();
+        final BoolQueryBuilder query = Query.bool();
+        if (StringUtil.isNotEmpty(condition.getTraceId())) {
+            query.must(Query.term(OTLPSpanRecord.TRACE_ID, condition.getTraceId()));
+        }
+        if (duration != null && duration.getStartTimestamp() > 0 && duration.getEndTimestamp() > 0) {
+            query.must(Query.range(OTLPSpanRecord.START_TIME)
+                            .gte(duration.getStartTimestamp())
+                            .lte(duration.getEndTimestamp()));
+        }
+        if (StringUtil.isNotEmpty(condition.getServiceName())) {
+            query.must(Query.term(OTLPSpanRecord.SERVICE_NAME, condition.getServiceName()));
+        }
+        if (StringUtil.isNotEmpty(condition.getServiceInstance())) {
+            query.must(Query.term(OTLPSpanRecord.SERVICE_INSTANCE, condition.getServiceInstance()));
+        }
+        if (StringUtil.isNotEmpty(condition.getScopeName())) {
+            query.must(Query.term(OTLPSpanRecord.SCOPE_NAME, condition.getScopeName()));
+        }
+        if (StringUtil.isNotEmpty(condition.getSpanName())) {
+            query.must(Query.term(OTLPSpanRecord.NAME, condition.getSpanName()));
+        }
+        if (StringUtil.isNotEmpty(condition.getPeerService())) {
+            query.must(Query.term(OTLPSpanRecord.PEER_SERVICE, condition.getPeerService()));
+        }
+        if (condition.getKind() != null) {
+            query.must(Query.term(OTLPSpanRecord.KIND, condition.getKind()));
+        }
+        if (condition.getStatusCode() != null) {
+            query.must(Query.term(OTLPSpanRecord.STATUS_CODE, condition.getStatusCode()));
+        }
+        if (condition.getMinDurationNanos() != null) {
+            query.must(Query.range(OTLPSpanRecord.DURATION).gte(condition.getMinDurationNanos()));
+        }
+        if (condition.getMaxDurationNanos() != null) {
+            query.must(Query.range(OTLPSpanRecord.DURATION).lte(condition.getMaxDurationNanos()));
+        }
+        if (CollectionUtils.isNotEmpty(condition.getTags())) {
+            for (final Tag tag : condition.getTags()) {
+                // one form for a scoped key, either of the two for an unscoped one
+                final List<String> forms = OTLPSpanRecord.indexedTags(tag.getKey(), tag.getValue());
+                query.must(forms.size() == 1
+                               ? Query.term(OTLPSpanRecord.TAGS, forms.get(0))
+                               : Query.terms(OTLPSpanRecord.TAGS, forms));
+            }
+        }
+
+        // One bucket per trace, ordered by the trace's earliest start or longest span, sized to the page.
+        final boolean byDuration = condition.getQueryOrder() == QueryOrder.BY_DURATION;
+        final String orderAggregation = byDuration ? OTLPSpanRecord.DURATION : OTLPSpanRecord.START_TIME;
+        final TermsAggregationBuilder traceIdAggregation =
+            Aggregation.terms(OTLPSpanRecord.TRACE_ID)
+                       .field(OTLPSpanRecord.TRACE_ID)
+                       .executionHint(TermsAggregationBuilder.ExecutionHint.MAP)
+                       .collectMode(TermsAggregationBuilder.CollectMode.BREADTH_FIRST)
+                       .size(condition.getLimit())
+                       .subAggregation(byDuration
+                                           ? Aggregation.max(orderAggregation).field(OTLPSpanRecord.DURATION)
+                                           : Aggregation.min(orderAggregation).field(OTLPSpanRecord.START_TIME))
+                       .order(BucketOrder.aggregation(orderAggregation, false));
+        final SearchBuilder search = Search.builder().query(query).aggregation(traceIdAggregation);
+        final SearchResponse traceIdResponse;
+        if (duration != null && duration.getStartTimestamp() > 0 && duration.getEndTimestamp() > 0) {
+            traceIdResponse = searchDebuggable(new TimeRangeIndexNameGenerator(
+                IndexController.LogicIndicesRegister.getPhysicalTableName(OTLPSpanRecord.INDEX_NAME),
+                TimeBucket.getRecordTimeBucket(duration.getStartTimestamp()),
+                TimeBucket.getRecordTimeBucket(duration.getEndTimestamp())
+            ), search.build());
+        } else {
+            traceIdResponse = searchDebuggable(
+                IndexController.LogicIndicesRegister.getPhysicalTableName(OTLPSpanRecord.INDEX_NAME), search.build());
+        }
+        final List<String> traceIds = new ArrayList<>();
+        if (Objects.nonNull(traceIdResponse.getAggregations())) {
+            final Map<String, Object> idTerms =
+                (Map<String, Object>) traceIdResponse.getAggregations().get(OTLPSpanRecord.TRACE_ID);
+            final List<Map<String, Object>> buckets =
+                (List<Map<String, Object>>) idTerms.get("buckets");
+            for (final Map<String, Object> idBucket : buckets) {
+                traceIds.add((String) idBucket.get("key"));
+            }
+        }
+        return fetchTraces(traceIds);
+    }
+
+    /**
+     * The spans of the given traces, grouped in the order the ids arrive in, which is the order the aggregation
+     * ranked them.
+     */
+    private List<List<SpanWrapper>> fetchTraces(final List<String> traceIds) {
+        if (traceIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        final String index = IndexController.LogicIndicesRegister.getPhysicalTableName(OTLPSpanRecord.INDEX_NAME);
+        final BoolQueryBuilder query = Query.bool().must(Query.terms(OTLPSpanRecord.TRACE_ID, traceIds));
+        final SearchBuilder search = Search.builder().query(query).sort(OTLPSpanRecord.START_TIME, Sort.Order.DESC)
+                                           .size(SCROLLING_BATCH_SIZE);
+        final SearchParams params = new SearchParams().scroll(SCROLL_CONTEXT_RETENTION);
+        RoutingUtils.addRoutingValuesToSearchParam(params, new HashSet<>(traceIds));
+        SearchResponse response = getClient().search(index, search.build(), params);
+        final Set<String> scrollIds = new HashSet<>();
+        final Map<String, List<SpanWrapper>> groupedByTraceId = new LinkedHashMap<>();
+        for (final String traceId : traceIds) {
+            groupedByTraceId.put(traceId, new ArrayList<>());
+        }
+        try {
+            while (response.getHits().getHits().size() != 0) {
+                final String scrollId = response.getScrollId();
+                scrollIds.add(scrollId);
+                for (final SearchHit searchHit : response.getHits()) {
+                    final Map<String, Object> source = searchHit.getSource();
+                    final String traceId = (String) source.get(OTLPSpanRecord.TRACE_ID);
+                    groupedByTraceId.computeIfAbsent(traceId, k -> new ArrayList<>()).add(toSpanWrapper(source));
+                }
+                if (response.getHits().getHits().size() < SCROLLING_BATCH_SIZE) {
+                    break;
+                }
+                response = getClient().scroll(SCROLL_CONTEXT_RETENTION, scrollId);
+            }
+        } finally {
+            scrollIds.forEach(getClient()::deleteScrollContextQuietly);
+        }
+        final List<List<SpanWrapper>> traces = new ArrayList<>(groupedByTraceId.size());
+        for (final List<SpanWrapper> trace : groupedByTraceId.values()) {
+            if (!trace.isEmpty()) {
+                traces.add(trace);
... 1931 lines suppressed ...