You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@hertzbeat.apache.org by zh...@apache.org on 2026/06/03 08:18:33 UTC

(hertzbeat) 23/50: feat(backend): harden OTLP ingestion and token scopes

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

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit f094cf7a2a0d49ac74b631dbaffad048c546f16f
Author: Logic <zq...@dromara.org>
AuthorDate: Fri May 29 09:34:43 2026 +0800

    feat(backend): harden OTLP ingestion and token scopes
    
    Validation:
    
    - ./mvnw -pl hertzbeat-common-core,hertzbeat-common-spring,hertzbeat-warehouse,hertzbeat-observability -DskipITs -Dskip.frontend -Dskip.npm -DskipDocker -DskipTests compile
    
    - ./mvnw -pl hertzbeat-common-core,hertzbeat-common-spring,hertzbeat-warehouse,hertzbeat-observability -DskipITs -Dskip.frontend -Dskip.npm -DskipDocker -DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false -Dtest='OtlpWorkspaceDtoMigrationTest,EntitySchemaLegacyColumnCompatibilityTest,GreptimeSqlQueryExecutorTest,GreptimeTraceQueryRepositoryTest,OpenTelemetryConfigTest,OtlpLogProtocolAdapterTest,OtlpGrpcServerConfigTest,OtlpIngestionControllerTest,OtlpLogControllerTest,OtlpLo [...]
    
    - ./mvnw -pl hertzbeat-startup -DskipITs -Dskip.frontend -Dskip.npm -DskipDocker -Dtest=ReleaseReadinessRuntimeAssemblyTest test
    
    - ./mvnw -pl hertzbeat-manager -Dcheckstyle.skip=true -DskipITs -Dskip.frontend -Dskip.npm -DskipDocker -Dtest='ApiTokenValidationFilterTest,AuthTokenControllerTest,AccountServiceTest,OtlpRouteAuthorizationConfigTest' test
    
    - git diff --cached --check
---
 .../dto/binding/OtlpEntityBindingSummaryDto.java   |   21 +
 .../dto/ingestion/OtlpIngestionRedSummaryDto.java  |   82 +
 .../dto/OtlpWorkspaceDtoMigrationTest.java         |    6 +-
 .../hertzbeat/common/entity/manager/AuthToken.java |   18 +
 .../entity/manager/EntityDefinitionActivity.java   |    6 +
 .../entity/manager/EntityGovernanceState.java      |   13 +-
 .../common/entity/manager/ObserveEntity.java       |   11 +-
 .../gateway/AuthTokenRequestContext.java           |   30 +-
 .../observability/gateway/AuthTokenScopes.java     |   81 +
 .../gateway/ObservabilityAccessTokenGateway.java   |   25 +-
 .../EntitySchemaLegacyColumnCompatibilityTest.java |   46 +
 .../src/test/resources/sureness.yml                |   16 +-
 .../manager/config/ApiTokenValidationFilter.java   |   67 +-
 .../manager/controller/AuthTokenController.java    |   10 +-
 .../apache/hertzbeat/manager/dao/AuthTokenDao.java |   39 +
 .../hertzbeat/manager/service/AccountService.java  |   22 +
 .../manager/service/impl/AccountServiceImpl.java   |  133 +-
 .../config/ApiTokenValidationFilterTest.java       |  115 +-
 .../config/OtlpRouteAuthorizationConfigTest.java   |   80 +
 .../controller/AuthTokenControllerTest.java        |   50 +-
 .../manager/service/AccountServiceTest.java        |  205 +-
 hertzbeat-manager/src/test/resources/sureness.yml  |   21 +-
 .../observability/config/OpenTelemetryConfig.java  |   35 +-
 .../ingestion/adapter/OtlpLogProtocolAdapter.java  |   65 +-
 .../GreptimeOtlpIngestionAuditEventReader.java     |  410 ++
 .../audit/GreptimeOtlpIngestionAuditEventSink.java |  300 ++
 .../OtlpIngestionAuditEvent.java}                  |   33 +-
 .../audit/OtlpIngestionAuditEventReader.java       |   62 +
 .../OtlpIngestionAuditEventSink.java}              |   21 +-
 .../ingestion/audit/OtlpIngestionAuditService.java |  188 +
 .../ingestion/config/OtlpGrpcServerConfig.java     |   85 +-
 .../controller/OtlpIngestionController.java        |   11 +
 .../ingestion/controller/OtlpLogController.java    |  143 +-
 .../controller/OtlpSignalIngestionController.java  |    9 +-
 .../ingestion/enricher/OtlpCorrelationContext.java |    6 +
 .../enricher/OtlpCorrelationEnricher.java          |   70 +-
 .../enricher/OtlpEntityIdentityResolver.java       |  238 +
 .../error/OtlpIngestionBackpressureHeaders.java    |  126 +
 .../error/OtlpIngestionErrorResponseFactory.java   |  260 ++
 .../forwarder/GreptimeApmFlowInitializer.java      |  176 +-
 .../forwarder/GreptimeLogPipelineInitializer.java  |   68 +-
 .../ingestion/forwarder/GreptimeOtlpForwarder.java |  237 +-
 .../forwarder/GreptimeTraceTableInitializer.java   |  153 +
 .../governance/OtlpIngestionGovernanceService.java |  150 +
 .../ingestion/quota/OtlpIngestionQuotaService.java |  271 ++
 .../red/OtlpIngestionRedSummaryService.java        |  478 ++
 .../redaction/OtlpIngestionRedactionService.java   |  160 +
 .../ingestion/retry/OtlpIngestionRetryService.java |  265 ++
 .../OtlpIngestionRequestContextResolver.java       |   53 +
 .../semantic/OtlpResourceSemanticAttributes.java   |   70 +
 .../service/OtlpGrpcIngestionService.java          |    2 +
 .../service/impl/OtlpGrpcIngestionServiceImpl.java | 1232 +++++-
 .../impl/OtlpIngestionWorkspaceServiceImpl.java    |  160 +-
 .../logs/service/impl/LogQueryServiceImpl.java     |  110 +-
 .../logs/service/impl/LogSseServiceImpl.java       |   11 +
 .../logs/sse/LogSseFilterCriteria.java             |   53 +
 .../service/impl/TelemetryIntakeServiceImpl.java   |  102 +-
 .../service/impl/EntityTraceQueryServiceImpl.java  |  322 +-
 .../greptime/flows/hertzbeat_apm_red_1m.sql        |   16 +-
 .../main/resources/greptime/tables/hzb_traces.sql  |   21 +
 .../config/OpenTelemetryConfigTest.java            |   59 +
 .../adapter/OtlpLogProtocolAdapterTest.java        |  147 +
 .../GreptimeOtlpIngestionAuditEventReaderTest.java |  967 ++++
 .../GreptimeOtlpIngestionAuditEventSinkTest.java   |  772 ++++
 .../audit/OtlpIngestionAuditEventReaderTest.java   |  127 +
 .../audit/OtlpIngestionAuditServiceTest.java       |  135 +
 .../OtlpIngestionAuditSourceOwnershipTest.java     |   57 +
 .../ingestion/config/OtlpGrpcServerConfigTest.java |   56 +-
 .../controller/OtlpIngestionControllerTest.java    |   71 +-
 .../OtlpLogControllerSourceOwnershipTest.java      |   42 +
 .../controller/OtlpLogControllerTest.java          |  437 +-
 .../OtlpSignalIngestionControllerTest.java         |   25 +
 .../enricher/OtlpCorrelationEnricherTest.java      |  147 +
 .../enricher/OtlpEntityIdentityResolverTest.java   |  161 +
 .../OtlpIngestionBackpressureHeadersTest.java      |  101 +
 .../OtlpIngestionErrorResponseFactoryTest.java     |  268 ++
 .../OtlpIngestionErrorSourceOwnershipTest.java     |   68 +
 .../forwarder/GreptimeApmFlowInitializerTest.java  |  249 +-
 .../GreptimeLogPipelineInitializerTest.java        |  216 +-
 .../forwarder/GreptimeOtlpForwarderTest.java       |  913 +++-
 .../GreptimeTraceTableInitializerTest.java         |  268 ++
 .../OtlpIngestionGovernanceServiceTest.java        |  101 +
 .../quota/OtlpIngestionQuotaServiceTest.java       |  236 +
 .../OtlpIngestionQuotaSourceOwnershipTest.java     |   70 +
 .../red/OtlpIngestionRedSummaryServiceTest.java    |  846 ++++
 ...OtlpIngestionRedSummarySourceOwnershipTest.java |   56 +
 .../OtlpIngestionRedactionServiceTest.java         |  121 +
 .../retry/OtlpIngestionRetryServiceTest.java       |  416 ++
 .../OtlpIngestionRequestContextResolverTest.java   |   84 +
 ...IngestionRequestContextSourceOwnershipTest.java |   55 +
 .../OtlpResourceSemanticAttributesTest.java        |   39 +
 .../impl/OtlpGrpcIngestionServiceImplTest.java     | 4620 +++++++++++++++++++-
 .../OtlpIngestionWorkspaceServiceImplTest.java     |  149 +-
 .../logs/controller/LogQueryControllerTest.java    |   95 +
 .../logs/controller/LogSseControllerTest.java      |   29 +
 .../impl/TelemetryIntakeServiceImplTest.java       |  115 +
 .../impl/EntityTraceQueryServiceImplTest.java      |  484 +-
 .../h2/V200__create_entity_foundation.sql          |   24 +-
 .../V213__backfill_entity_governance_workspace.sql |   32 +
 .../mysql/V200__create_entity_foundation.sql       |   20 +-
 .../V213__backfill_entity_governance_workspace.sql |   73 +
 .../postgresql/V200__create_entity_foundation.sql  |   24 +-
 .../V213__backfill_entity_governance_workspace.sql |   32 +
 hertzbeat-startup/src/main/resources/sureness.yml  |   21 +-
 .../ReleaseReadinessRuntimeAssemblyTest.java       |  131 +
 .../warehouse/db/GreptimeSqlQueryExecutor.java     |   75 +-
 .../repository/GreptimeTraceQueryRepository.java   |  469 +-
 .../warehouse/repository/TraceQueryRepository.java |  153 +
 .../warehouse/db/GreptimeSqlQueryExecutorTest.java |  152 +
 .../GreptimeTraceQueryRepositoryTest.java          |  230 +-
 pom.xml                                            |   16 +
 .../hertzbeat-mysql-iotdb/conf/sureness.yml        |   20 +-
 .../hertzbeat-mysql-tdengine/conf/sureness.yml     |   20 +-
 .../conf/sureness.yml                              |   20 +-
 .../conf/sureness.yml                              |   20 +-
 .../conf/sureness.yml                              |   20 +-
 script/sureness.yml                                |   23 +-
 117 files changed, 20829 insertions(+), 817 deletions(-)

diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/binding/OtlpEntityBindingSummaryDto.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/binding/OtlpEntityBindingSummaryDto.java
index b5a0b5797f..d407148c96 100644
--- a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/binding/OtlpEntityBindingSummaryDto.java
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/binding/OtlpEntityBindingSummaryDto.java
@@ -18,6 +18,7 @@
 package org.apache.hertzbeat.common.observability.dto.binding;
 
 import java.util.List;
+import java.util.Map;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.NoArgsConstructor;
@@ -38,6 +39,8 @@ public class OtlpEntityBindingSummaryDto {
 
     private List<BoundEntity> recentBoundEntities;
 
+    private List<UnboundEntityCandidate> recentUnboundCandidates;
+
     /**
      * Recent canonical identity sample derived from telemetry intake.
      */
@@ -66,4 +69,22 @@ public class OtlpEntityBindingSummaryDto {
         private String primaryIdentityValue;
         private long monitorBindCount;
     }
+
+    /**
+     * Recent telemetry identity that has not been confirmed as a HertzBeat entity.
+     */
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class UnboundEntityCandidate {
+        private String suggestedName;
+        private String suggestedType;
+        private String namespace;
+        private String environment;
+        private String primaryIdentityKey;
+        private String primaryIdentityValue;
+        private List<String> signals;
+        private Map<String, String> canonicalIdentities;
+        private Long latestObservedAt;
+    }
 }
diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/ingestion/OtlpIngestionRedSummaryDto.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/ingestion/OtlpIngestionRedSummaryDto.java
new file mode 100644
index 0000000000..fbe6c83185
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/ingestion/OtlpIngestionRedSummaryDto.java
@@ -0,0 +1,82 @@
+/*
+ * 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.hertzbeat.common.observability.dto.ingestion;
+
+import java.util.List;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Recent OTLP ingest request/error/duration summary.
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OtlpIngestionRedSummaryDto {
+
+    private String workspaceId;
+
+    private long requestCount;
+
+    private double requestRatePerMinute;
+
+    private long errorCount;
+
+    private double errorRate;
+
+    private long requestBytes;
+
+    private long signalItems;
+
+    private Long averageSignalItems;
+
+    private Long maxSignalItems;
+
+    private Long averageDurationMillis;
+
+    private Long maxDurationMillis;
+
+    private Long latestObservedAt;
+
+    private List<SignalRedMetric> signals;
+
+    /**
+     * Signal and protocol-level RED summary.
+     */
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class SignalRedMetric {
+        private String signal;
+        private String protocol;
+        private long requestCount;
+        private double requestRatePerMinute;
+        private long errorCount;
+        private double errorRate;
+        private long requestBytes;
+        private long signalItems;
+        private Long averageSignalItems;
+        private Long maxSignalItems;
+        private Long averageDurationMillis;
+        private Long maxDurationMillis;
+        private String latestStatusCode;
+        private String latestReason;
+        private Long latestObservedAt;
+    }
+}
diff --git a/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/observability/dto/OtlpWorkspaceDtoMigrationTest.java b/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/observability/dto/OtlpWorkspaceDtoMigrationTest.java
index 9208f57b1f..56f77cffd9 100644
--- a/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/observability/dto/OtlpWorkspaceDtoMigrationTest.java
+++ b/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/observability/dto/OtlpWorkspaceDtoMigrationTest.java
@@ -70,7 +70,11 @@ class OtlpWorkspaceDtoMigrationTest {
                 List.of("service.name"),
                 List.of("checkout"),
                 List.of(new OtlpEntityBindingSummaryDto.CanonicalIdentitySample("service.name", "checkout", "logs")),
-                List.of(new OtlpEntityBindingSummaryDto.BoundEntity(1L, "service", "checkout", "Checkout", "commerce", "service.name", "checkout", 2L))
+                List.of(new OtlpEntityBindingSummaryDto.BoundEntity(1L, "service", "checkout", "Checkout",
+                        "commerce", "service.name", "checkout", 2L)),
+                List.of(new OtlpEntityBindingSummaryDto.UnboundEntityCandidate(
+                        "billing", "service", "commerce", "prod", "service.name", "billing",
+                        List.of("metrics"), Map.of("service.name", "billing"), 2000L))
         );
         TelemetryIdentitySnapshot identitySnapshot = new TelemetryIdentitySnapshot(
                 "otlp",
diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/AuthToken.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/AuthToken.java
index 1845c36e91..0ce7721dad 100644
--- a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/AuthToken.java
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/AuthToken.java
@@ -29,6 +29,7 @@ import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.springframework.data.annotation.CreatedBy;
 import org.springframework.data.annotation.CreatedDate;
 import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@@ -72,6 +73,16 @@ public class AuthToken {
     @Column(length = 64)
     private String tokenMask;
 
+    @Schema(title = "Token scope", example = "api-admin", accessMode = READ_WRITE)
+    @Column(name = "token_scope", length = 32)
+    @Builder.Default
+    private String tokenScope = AuthTokenScopes.API_ADMIN;
+
+    @Schema(title = "Token workspace boundary", example = "default", accessMode = READ_WRITE)
+    @Column(name = "workspace_id", length = 64)
+    @Builder.Default
+    private String workspaceId = AuthTokenScopes.DEFAULT_WORKSPACE_ID;
+
     @Schema(title = "Token status: 0-active", accessMode = READ_ONLY)
     @Column(nullable = false)
     @Builder.Default
@@ -91,4 +102,11 @@ public class AuthToken {
     @Schema(title = "Token last used time", accessMode = READ_ONLY)
     private LocalDateTime lastUsedTime;
 
+    @Schema(title = "Token revoked time", accessMode = READ_ONLY)
+    private LocalDateTime revokedTime;
+
+    @Schema(title = "Token revoker", accessMode = READ_ONLY)
+    @Column(length = 64)
+    private String revokedBy;
+
 }
diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/EntityDefinitionActivity.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/EntityDefinitionActivity.java
index 8d26a2a722..14e8a7d5d1 100644
--- a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/EntityDefinitionActivity.java
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/EntityDefinitionActivity.java
@@ -64,6 +64,12 @@ public class EntityDefinitionActivity {
     @Column(name = "entity_id", nullable = false)
     private Long entityId;
 
+    @Schema(title = "Workspace ID", example = "default", accessMode = READ_WRITE)
+    @Size(max = 64)
+    @Builder.Default
+    @Column(name = "workspace_id", length = 64)
+    private String workspaceId = "default";
+
     @Schema(title = "Activity type", example = "definition_import", accessMode = READ_WRITE)
     @Size(max = 32)
     @Column(name = "activity_type", length = 32, nullable = false)
diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/EntityGovernanceState.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/EntityGovernanceState.java
index bf900fccc9..101199e916 100644
--- a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/EntityGovernanceState.java
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/EntityGovernanceState.java
@@ -37,6 +37,7 @@ import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.springframework.data.annotation.CreatedBy;
 import org.springframework.data.annotation.CreatedDate;
 import org.springframework.data.annotation.LastModifiedDate;
@@ -49,11 +50,13 @@ import tools.jackson.databind.JsonNode;
 @Entity
 @Table(name = "hzb_entity_governance_state",
         uniqueConstraints = {
-                @UniqueConstraint(name = "uk_hzb_entity_governance_state_scope_kind_key",
-                        columnNames = {"state_scope", "state_kind", "state_key"})
+                @UniqueConstraint(name = "uk_hzb_entity_governance_state_scope_kind_workspace_key",
+                        columnNames = {"state_scope", "state_kind", "workspace_id", "state_key"})
         },
         indexes = {
                 @Index(name = "idx_hzb_entity_governance_state_scope_kind", columnList = "state_scope,state_kind"),
+                @Index(name = "idx_hzb_entity_governance_state_scope_kind_workspace",
+                        columnList = "state_scope,state_kind,workspace_id"),
                 @Index(name = "idx_hzb_entity_governance_state_update", columnList = "gmt_update"),
                 @Index(name = "idx_hzb_entity_governance_state_creator", columnList = "creator")
         })
@@ -80,6 +83,12 @@ public class EntityGovernanceState {
     @Column(name = "state_kind", length = 32, nullable = false)
     private String stateKind;
 
+    @Schema(title = "Workspace ID", example = "default", accessMode = READ_WRITE)
+    @Size(max = 64)
+    @Column(name = "workspace_id", length = 64)
+    @Builder.Default
+    private String workspaceId = AuthTokenScopes.DEFAULT_WORKSPACE_ID;
+
     @Schema(title = "State key", example = "preset-1710000000", accessMode = READ_WRITE)
     @Size(max = 128)
     @Column(name = "state_key", length = 128, nullable = false)
diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/ObserveEntity.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/ObserveEntity.java
index 86405384e7..28619ef7c9 100644
--- a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/ObserveEntity.java
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/ObserveEntity.java
@@ -33,9 +33,11 @@ import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
 import lombok.AllArgsConstructor;
+import lombok.Builder.Default;
 import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.common.entity.alerter.JsonMapAttributeConverter;
 import org.springframework.data.annotation.CreatedBy;
 import org.springframework.data.annotation.CreatedDate;
@@ -52,7 +54,8 @@ import tools.jackson.databind.JsonNode;
         @Index(name = "idx_hzb_entity_type", columnList = "entity_type"),
         @Index(name = "idx_hzb_entity_status", columnList = "status"),
         @Index(name = "idx_hzb_entity_name", columnList = "name"),
-        @Index(name = "idx_hzb_entity_owner", columnList = "owner")
+        @Index(name = "idx_hzb_entity_owner", columnList = "owner"),
+        @Index(name = "idx_hzb_entity_workspace", columnList = "workspace_id")
 })
 @Data
 @Builder
@@ -211,6 +214,12 @@ public class ObserveEntity {
     @Column(columnDefinition = "TEXT")
     private List<String> tags;
 
+    @Schema(title = "Entity workspace boundary", example = "default", accessMode = READ_WRITE)
+    @Size(max = 64)
+    @Column(name = "workspace_id", length = 64)
+    @Default
+    private String workspaceId = AuthTokenScopes.DEFAULT_WORKSPACE_ID;
+
     @Schema(title = "The creator of this record", example = "tom", accessMode = READ_ONLY)
     @CreatedBy
     private String creator;
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/AuthTokenRequestContext.java
similarity index 54%
copy from hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
copy to hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/AuthTokenRequestContext.java
index d4398dd193..032895eadf 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/AuthTokenRequestContext.java
@@ -15,24 +15,34 @@
  * limitations under the License.
  */
 
-package org.apache.hertzbeat.observability.ingestion.enricher;
+package org.apache.hertzbeat.common.observability.gateway;
 
-import java.util.UUID;
 import org.apache.commons.lang3.StringUtils;
 
 /**
- * Request-scoped HertzBeat correlation values to inject into OTLP records.
+ * Per-request authentication boundary for downstream workspace-aware services.
  */
-public record OtlpCorrelationContext(String ingestId, String entityId, String workspaceId) {
+public final class AuthTokenRequestContext {
 
-    public static OtlpCorrelationContext empty() {
-        return new OtlpCorrelationContext(null, null, null);
+    private static final ThreadLocal<String> WORKSPACE_ID = new ThreadLocal<>();
+
+    private AuthTokenRequestContext() {
     }
 
-    OtlpCorrelationContext withIngestId() {
-        if (StringUtils.isNotBlank(ingestId)) {
-            return this;
+    public static void bindWorkspaceId(String workspaceId) {
+        String normalized = StringUtils.trimToNull(workspaceId);
+        if (normalized == null) {
+            clear();
+            return;
         }
-        return new OtlpCorrelationContext(UUID.randomUUID().toString(), entityId, workspaceId);
+        WORKSPACE_ID.set(AuthTokenScopes.normalizeWorkspaceId(normalized));
+    }
+
+    public static String currentWorkspaceId() {
+        return WORKSPACE_ID.get();
+    }
+
+    public static void clear() {
+        WORKSPACE_ID.remove();
     }
 }
diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/AuthTokenScopes.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/AuthTokenScopes.java
new file mode 100644
index 0000000000..dc6671135f
--- /dev/null
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/AuthTokenScopes.java
@@ -0,0 +1,81 @@
+/*
+ * 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.hertzbeat.common.observability.gateway;
+
+import java.util.Locale;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Shared API token scope names and matching rules.
+ */
+public final class AuthTokenScopes {
+
+    public static final String CLAIM_TOKEN_SCOPE = "token_scope";
+    public static final String CLAIM_WORKSPACE_ID = "workspace_id";
+    public static final String WORKSPACE_ID_HEADER = "X-HertzBeat-Workspace-Id";
+    public static final String DEFAULT_WORKSPACE_ID = "default";
+    public static final String UI_SESSION = "ui-session";
+    public static final String API_ADMIN = "api-admin";
+    public static final String READONLY_QUERY = "readonly-query";
+    public static final String OTLP_INGEST = "otlp-ingest";
+
+    private static final Set<String> API_TOKEN_SCOPES = Set.of(API_ADMIN, READONLY_QUERY, OTLP_INGEST);
+    private static final Set<String> REQUIRED_SCOPES = Set.of(API_ADMIN, READONLY_QUERY, OTLP_INGEST, UI_SESSION);
+
+    private AuthTokenScopes() {
+    }
+
+    public static String normalizeApiTokenScope(String tokenScope) {
+        String normalized = normalizeScopeValue(tokenScope, API_ADMIN);
+        if (!API_TOKEN_SCOPES.contains(normalized)) {
+            throw new IllegalArgumentException("Unsupported API token scope: " + tokenScope);
+        }
+        return normalized;
+    }
+
+    public static String normalizeRequiredScope(String requiredScope) {
+        String normalized = normalizeScopeValue(requiredScope, API_ADMIN);
+        if (!REQUIRED_SCOPES.contains(normalized)) {
+            throw new IllegalArgumentException("Unsupported required token scope: " + requiredScope);
+        }
+        return normalized;
+    }
+
+    public static Set<String> allowedTokenScopesFor(String requiredScope) {
+        return switch (normalizeRequiredScope(requiredScope)) {
+            case API_ADMIN -> Set.of(API_ADMIN);
+            case READONLY_QUERY -> Set.of(API_ADMIN, READONLY_QUERY);
+            case OTLP_INGEST -> Set.of(API_ADMIN, OTLP_INGEST);
+            case UI_SESSION -> Set.of(UI_SESSION);
+            default -> Set.of();
+        };
+    }
+
+    public static String normalizeWorkspaceId(String workspaceId) {
+        String normalized = StringUtils.trimToNull(workspaceId);
+        return normalized == null ? DEFAULT_WORKSPACE_ID : normalized;
+    }
+
+    private static String normalizeScopeValue(String scope, String defaultScope) {
+        if (StringUtils.isBlank(scope)) {
+            return defaultScope;
+        }
+        return scope.trim().toLowerCase(Locale.ROOT);
+    }
+}
diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/ObservabilityAccessTokenGateway.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/ObservabilityAccessTokenGateway.java
index 377d4aefd9..a78f3bc408 100644
--- a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/ObservabilityAccessTokenGateway.java
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/gateway/ObservabilityAccessTokenGateway.java
@@ -32,7 +32,30 @@ public interface ObservabilityAccessTokenGateway {
      * @param tokenValue raw token value
      * @return null when token is valid, otherwise rejection reason
      */
-    String checkTokenStatus(String tokenValue);
+    default String checkTokenStatus(String tokenValue) {
+        return checkTokenStatus(tokenValue, AuthTokenScopes.API_ADMIN);
+    }
+
+    /**
+     * Check the status of a managed token for a required scope.
+     *
+     * @param tokenValue raw token value
+     * @param requiredScope required access boundary
+     * @return null when token is valid for the required scope, otherwise rejection reason
+     */
+    String checkTokenStatus(String tokenValue, String requiredScope);
+
+    /**
+     * Check the status of a managed token for a required scope and workspace boundary.
+     *
+     * @param tokenValue raw token value
+     * @param requiredScope required access boundary
+     * @param workspaceId requested workspace boundary
+     * @return null when token is valid for the required scope/workspace, otherwise rejection reason
+     */
+    default String checkTokenStatus(String tokenValue, String requiredScope, String workspaceId) {
+        return checkTokenStatus(tokenValue, requiredScope);
+    }
 
     /**
      * Check whether the owner behind a managed token is still allowed.
diff --git a/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/manager/EntitySchemaLegacyColumnCompatibilityTest.java b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/manager/EntitySchemaLegacyColumnCompatibilityTest.java
new file mode 100644
index 0000000000..48c473fac2
--- /dev/null
+++ b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/manager/EntitySchemaLegacyColumnCompatibilityTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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.hertzbeat.common.entity.manager;
+
+import jakarta.persistence.Column;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Flyway keeps these columns strict for fresh installs; JPA metadata must stay
+ * legacy-upgrade friendly so Hibernate can add columns to existing local H2 tables.
+ */
+class EntitySchemaLegacyColumnCompatibilityTest {
+
+    @Test
+    void additiveBackfillColumnsShouldRemainJpaNullable() throws NoSuchFieldException {
+        assertColumnNullable(AuthToken.class, "tokenScope");
+        assertColumnNullable(AuthToken.class, "workspaceId");
+        assertColumnNullable(ObserveEntity.class, "workspaceId");
+        assertColumnNullable(EntityDefinitionActivity.class, "workspaceId");
+        assertColumnNullable(EntityGovernanceState.class, "workspaceId");
+    }
+
+    private static void assertColumnNullable(Class<?> entityClass, String fieldName) throws NoSuchFieldException {
+        Column column = entityClass.getDeclaredField(fieldName).getAnnotation(Column.class);
+
+        assertTrue(column.nullable(), () -> entityClass.getSimpleName() + "." + fieldName
+                + " must allow legacy Hibernate add-column backfill; enforce NOT NULL in Flyway instead.");
+    }
+}
diff --git a/hertzbeat-e2e/hertzbeat-observability-e2e/src/test/resources/sureness.yml b/hertzbeat-e2e/hertzbeat-observability-e2e/src/test/resources/sureness.yml
index 9809632a67..7506824b2d 100644
--- a/hertzbeat-e2e/hertzbeat-observability-e2e/src/test/resources/sureness.yml
+++ b/hertzbeat-e2e/hertzbeat-observability-e2e/src/test/resources/sureness.yml
@@ -22,13 +22,25 @@
 resourceRole:
   - /api/account/auth/refresh===post===[admin,user,guest]
   - /api/apps/**===get===[admin,user,guest]
+  - /api/monitor===get===[admin,user,guest]
   - /api/monitor/**===get===[admin,user,guest]
+  - /api/monitor===post===[admin,user]
   - /api/monitor/**===post===[admin,user]
+  - /api/monitor===put===[admin,user]
   - /api/monitor/**===put===[admin,user]
-  - /api/monitor/**===delete==[admin]
+  - /api/monitor===delete===[admin]
+  - /api/monitor/**===delete===[admin]
+  - /api/monitors===get===[admin,user,guest]
+  - /api/monitors/export===get===[admin]
+  - /api/monitors/export/all===get===[admin]
+  - /api/monitors/manage===get===[admin,user,guest]
+  - /api/monitors/manage===post===[admin,user]
+  - /api/monitors/manage===put===[admin,user]
   - /api/monitors/**===get===[admin,user,guest]
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
+  - /api/monitors===delete===[admin]
+  - /api/monitors/manage===delete===[admin]
   - /api/monitors/**===delete===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
@@ -62,6 +74,8 @@ resourceRole:
   - /api/grafana/**===post===[admin,user]
   - /api/grafana/**===put===[admin,user]
   - /api/grafana/**===delete===[admin]
+  - /api/otlp/**===post===[admin,user]
+  - /api/logs/otlp/**===post===[admin,user]
   - /api/bulletin/**===get===[admin,user,guest]
   - /api/bulletin/**===post===[admin,user]
   - /api/bulletin/**===put===[admin,user]
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java
index 4075d2e1d6..ed4001ea37 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java
@@ -30,8 +30,11 @@ import java.util.List;
 import java.util.Map;
 
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.hertzbeat.collector.dispatch.DispatchConstants;
 import org.apache.hertzbeat.common.constants.NetworkConstants;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.common.util.JsonUtil;
 import org.apache.hertzbeat.manager.service.AccountService;
 import org.apache.hertzbeat.manager.service.impl.AccountServiceImpl;
@@ -73,7 +76,9 @@ public class ApiTokenValidationFilter implements HandlerInterceptor {
     @Override
     public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler)
         throws IOException {
+        AuthTokenRequestContext.clear();
         SubjectSum subject = SurenessContextHolder.getBindSubject();
+        bindRequestedWorkspace(request, subject);
         if (subject == null || !isManagedToken(subject)) {
             return true;
         }
@@ -83,7 +88,11 @@ public class ApiTokenValidationFilter implements HandlerInterceptor {
             String token = authorization.substring(DispatchConstants.BEARER.length()).trim();
             if (!token.isEmpty()) {
                 try {
-                    String rejectReason = checkManagedToken(subject, token);
+                    String rejectReason = checkManagedToken(
+                            subject,
+                            token,
+                            resolveRequiredScope(request),
+                            AuthTokenRequestContext.currentWorkspaceId());
                     if (rejectReason != null) {
                         return writeError(response, HttpStatus.UNAUTHORIZED, rejectReason);
                     }
@@ -97,6 +106,12 @@ public class ApiTokenValidationFilter implements HandlerInterceptor {
         return true;
     }
 
+    @Override
+    public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
+                                @NonNull Object handler, Exception ex) {
+        AuthTokenRequestContext.clear();
+    }
+
     /**
      * Check if a managed token should be rejected.
      * <p>
@@ -108,8 +123,10 @@ public class ApiTokenValidationFilter implements HandlerInterceptor {
      * If the token is valid, updates the last used time.
      * </p>
      */
-    private String checkManagedToken(SubjectSum subject, String token) {
-        String rejectReason = accountService.checkTokenStatus(token);
+    private String checkManagedToken(SubjectSum subject, String token, String requiredScope, String workspaceId) {
+        String rejectReason = StringUtils.isBlank(workspaceId)
+                ? accountService.checkTokenStatus(token, requiredScope)
+                : accountService.checkTokenStatus(token, requiredScope, workspaceId);
         if (rejectReason != null) {
             return rejectReason;
         }
@@ -156,6 +173,50 @@ public class ApiTokenValidationFilter implements HandlerInterceptor {
         return principal == null ? null : String.valueOf(principal);
     }
 
+    private String resolveRequiredScope(HttpServletRequest request) {
+        String requestUri = request.getRequestURI();
+        if (requestUri != null
+            && (requestUri.startsWith("/api/otlp/") || requestUri.startsWith("/api/logs/otlp"))) {
+            return AuthTokenScopes.OTLP_INGEST;
+        }
+        String method = request.getMethod();
+        if ("GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method) || "OPTIONS".equalsIgnoreCase(method)) {
+            return AuthTokenScopes.READONLY_QUERY;
+        }
+        return AuthTokenScopes.API_ADMIN;
+    }
+
+    private void bindRequestedWorkspace(HttpServletRequest request, SubjectSum subject) {
+        String workspaceId = resolveRequestedWorkspaceId(request, subject);
+        if (workspaceId != null) {
+            AuthTokenRequestContext.bindWorkspaceId(workspaceId);
+        }
+    }
+
+    private String resolveRequestedWorkspaceId(HttpServletRequest request, SubjectSum subject) {
+        String workspaceId = StringUtils.trimToNull(request.getHeader(AuthTokenScopes.WORKSPACE_ID_HEADER));
+        if (workspaceId != null) {
+            return workspaceId;
+        }
+        workspaceId = StringUtils.trimToNull(request.getParameter("workspaceId"));
+        if (workspaceId != null) {
+            return workspaceId;
+        }
+        workspaceId = StringUtils.trimToNull(request.getParameter("workspace_id"));
+        if (workspaceId != null) {
+            return workspaceId;
+        }
+        return resolveSubjectWorkspaceId(subject);
+    }
+
+    private String resolveSubjectWorkspaceId(SubjectSum subject) {
+        if (subject == null || subject.getPrincipalMap() == null) {
+            return null;
+        }
+        Object workspaceId = subject.getPrincipalMap().getPrincipal(AuthTokenScopes.CLAIM_WORKSPACE_ID);
+        return workspaceId == null ? null : StringUtils.trimToNull(String.valueOf(workspaceId));
+    }
+
     private boolean writeError(HttpServletResponse response, HttpStatus status, String message) throws IOException {
         response.setStatus(status.value());
         response.setCharacterEncoding("UTF-8");
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AuthTokenController.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AuthTokenController.java
index 4aa3abb954..5b17e8fae6 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AuthTokenController.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AuthTokenController.java
@@ -28,8 +28,10 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.hertzbeat.common.entity.dto.Message;
 import org.apache.hertzbeat.common.entity.manager.AuthToken;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.manager.service.AccountService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.ResponseEntity;
@@ -57,7 +59,9 @@ public class AuthTokenController {
     @Operation(summary = "Generate a new API token", description = "Generate a new API token for integrations, optionally with expiration")
     public ResponseEntity<Message<Map<String, String>>> generateToken(
             @RequestParam(value = "name", required = false) @Parameter(description = "Token name/description") String name,
-            @RequestParam(value = "expireSeconds", required = false) @Parameter(description = "Expiration time in seconds, null means never expire") Long expireSeconds) {
+            @RequestParam(value = "expireSeconds", required = false) @Parameter(description = "Expiration time in seconds, null means never expire") Long expireSeconds,
+            @RequestParam(value = "scope", required = false, defaultValue = AuthTokenScopes.API_ADMIN) @Parameter(description = "Token access scope") String scope,
+            @RequestParam(value = "workspaceId", required = false) @Parameter(description = "Token workspace boundary") String workspaceId) {
         SubjectSum subjectSum = SurenessContextHolder.getBindSubject();
         if (subjectSum == null) {
             return ResponseEntity.ok(Message.fail(FAIL_CODE, "No login user"));
@@ -66,7 +70,9 @@ public class AuthTokenController {
             return ResponseEntity.ok(Message.fail(FAIL_CODE, "No permission"));
         }
         try {
-            String token = accountService.generateToken(name, expireSeconds);
+            String token = StringUtils.isBlank(workspaceId)
+                    ? accountService.generateToken(name, expireSeconds, scope)
+                    : accountService.generateToken(name, expireSeconds, scope, workspaceId);
             Map<String, String> rep = Collections.singletonMap("token", token);
             return ResponseEntity.ok(Message.success(rep));
         } catch (Exception e) {
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/AuthTokenDao.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/AuthTokenDao.java
index d185cdaecd..49b0f1be50 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/AuthTokenDao.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/AuthTokenDao.java
@@ -26,6 +26,7 @@ import org.springframework.data.repository.query.Param;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
+import java.util.Collection;
 import java.util.List;
 
 /**
@@ -43,11 +44,49 @@ public interface AuthTokenDao extends JpaRepository<AuthToken, Long>, JpaSpecifi
      */
     List<AuthToken> findByStatusAndCreator(Byte status, String creator);
 
+    /**
+     * Find active tokens inside one workspace.
+     */
+    List<AuthToken> findByStatusAndWorkspaceId(Byte status, String workspaceId);
+
+    /**
+     * Find active tokens created by the given user inside one workspace.
+     */
+    List<AuthToken> findByStatusAndCreatorAndWorkspaceId(Byte status, String creator, String workspaceId);
+
     /**
      * Check if an active token exists with the given hash
      */
     boolean existsByTokenHashAndStatus(String tokenHash, Byte status);
 
+    /**
+     * Check if an active token exists with the given hash and one of the allowed scopes
+     */
+    boolean existsByTokenHashAndStatusAndTokenScopeIn(String tokenHash, Byte status, Collection<String> tokenScopes);
+
+    /**
+     * Check if an active token exists with the given hash, scope, and workspace boundary.
+     */
+    boolean existsByTokenHashAndStatusAndTokenScopeInAndWorkspaceId(
+            String tokenHash,
+            Byte status,
+            Collection<String> tokenScopes,
+            String workspaceId);
+
+    /**
+     * Count active tokens created by the given user for one scope.
+     */
+    long countByStatusAndCreatorAndTokenScope(Byte status, String creator, String tokenScope);
+
+    /**
+     * Count active tokens created by the given user for one scope and workspace.
+     */
+    long countByStatusAndCreatorAndTokenScopeAndWorkspaceId(
+            Byte status,
+            String creator,
+            String tokenScope,
+            String workspaceId);
+
     /**
      * Update last used time for a token identified by its hash
      */
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/AccountService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/AccountService.java
index 249c43e213..b2ff12b0b9 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/AccountService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/AccountService.java
@@ -57,6 +57,28 @@ public interface AccountService extends ObservabilityAccessTokenGateway {
      */
     String generateToken(String tokenName, Long expireSeconds) throws AuthenticationException;
 
+    /**
+     * Generate a managed API token with a specific access scope.
+     *
+     * @param tokenName     optional name/description for the token
+     * @param expireSeconds optional expiration time in seconds, null means never expire
+     * @param tokenScope    token access scope
+     * @return token string
+     */
+    String generateToken(String tokenName, Long expireSeconds, String tokenScope) throws AuthenticationException;
+
+    /**
+     * Generate a managed API token with a specific access scope and workspace boundary.
+     *
+     * @param tokenName     optional name/description for the token
+     * @param expireSeconds optional expiration time in seconds, null means never expire
+     * @param tokenScope    token access scope
+     * @param workspaceId   token workspace boundary
+     * @return token string
+     */
+    String generateToken(String tokenName, Long expireSeconds, String tokenScope, String workspaceId)
+            throws AuthenticationException;
+
     /**
      * List all API tokens
      *
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AccountServiceImpl.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AccountServiceImpl.java
index 1cadceb28d..257a8fc42b 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AccountServiceImpl.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AccountServiceImpl.java
@@ -31,6 +31,8 @@ import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hertzbeat.alert.util.CryptoUtils;
 import org.apache.hertzbeat.common.entity.manager.AuthToken;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.common.util.JsonUtil;
 import org.apache.hertzbeat.manager.dao.AuthTokenDao;
 import org.apache.hertzbeat.manager.pojo.dto.LoginDto;
@@ -47,6 +49,7 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 
@@ -72,6 +75,10 @@ public class AccountServiceImpl implements AccountService {
 
     private static final byte TOKEN_STATUS_ACTIVE = 0;
 
+    private static final byte TOKEN_STATUS_REVOKED = 1;
+
+    private static final long MAX_ACTIVE_TOKENS_PER_SCOPE_PER_USER = 20;
+
     /**
      * Custom JWT claim key to mark tokens as managed (persisted in DB for lifecycle management).
      * Only tokens with this claim will be validated against the database.
@@ -169,6 +176,17 @@ public class AccountServiceImpl implements AccountService {
 
     @Override
     public String generateToken(String tokenName, Long expireSeconds) throws AuthenticationException {
+        return generateToken(tokenName, expireSeconds, AuthTokenScopes.API_ADMIN);
+    }
+
+    @Override
+    public String generateToken(String tokenName, Long expireSeconds, String tokenScope) throws AuthenticationException {
+        return generateToken(tokenName, expireSeconds, tokenScope, AuthTokenScopes.DEFAULT_WORKSPACE_ID);
+    }
+
+    @Override
+    public String generateToken(String tokenName, Long expireSeconds, String tokenScope, String workspaceId)
+            throws AuthenticationException {
         SubjectSum subjectSum = requireCurrentSubject();
         String userId = getCurrentUserId(subjectSum);
         SurenessAccount account = accountProvider.loadAccount(userId);
@@ -178,8 +196,15 @@ public class AccountServiceImpl implements AccountService {
         if (account.isDisabledAccount() || account.isExcessiveAttempts()) {
             throw new AuthenticationException("Expired or Illegal Account");
         }
+        String normalizedScope = AuthTokenScopes.normalizeApiTokenScope(tokenScope);
+        String normalizedWorkspaceId = AuthTokenScopes.normalizeWorkspaceId(workspaceId);
+        long activeTokens = authTokenDao.countByStatusAndCreatorAndTokenScopeAndWorkspaceId(
+            TOKEN_STATUS_ACTIVE, userId, normalizedScope, normalizedWorkspaceId);
+        if (activeTokens >= MAX_ACTIVE_TOKENS_PER_SCOPE_PER_USER) {
+            throw new AuthenticationException("Token quota exceeded");
+        }
         List<String> roles = account.getOwnRoles();
-        String token = issueApiToken(userId, roles, expireSeconds);
+        String token = issueApiToken(userId, roles, expireSeconds, normalizedScope, normalizedWorkspaceId);
 
         // Persist token metadata for management
         String tokenHash = CryptoUtils.sha256Hex(token);
@@ -192,6 +217,8 @@ public class AccountServiceImpl implements AccountService {
             .name(tokenName)
             .tokenHash(tokenHash)
             .tokenMask(tokenMask)
+            .tokenScope(normalizedScope)
+            .workspaceId(normalizedWorkspaceId)
             .status(TOKEN_STATUS_ACTIVE)
             .creator(userId)
             .expireTime(expireTime)
@@ -202,7 +229,10 @@ public class AccountServiceImpl implements AccountService {
     }
 
     private String issueAccessToken(String userId, List<String> roles, long expirationSeconds) {
-        return JsonWebTokenUtil.issueJwt(userId, expirationSeconds, roles, new HashMap<>(0));
+        Map<String, Object> customClaimMap = new HashMap<>(2);
+        customClaimMap.put(AuthTokenScopes.CLAIM_TOKEN_SCOPE, AuthTokenScopes.UI_SESSION);
+        customClaimMap.put(AuthTokenScopes.CLAIM_WORKSPACE_ID, AuthTokenScopes.DEFAULT_WORKSPACE_ID);
+        return JsonWebTokenUtil.issueJwt(userId, expirationSeconds, roles, customClaimMap);
     }
 
     @Override
@@ -211,6 +241,17 @@ public class AccountServiceImpl implements AccountService {
         if (subjectSum == null) {
             return List.of();
         }
+        String workspaceId = AuthTokenRequestContext.currentWorkspaceId();
+        if (StringUtils.isNotBlank(workspaceId)) {
+            String normalizedWorkspaceId = AuthTokenScopes.normalizeWorkspaceId(workspaceId);
+            if (subjectSum.hasRole("admin")) {
+                return authTokenDao.findByStatusAndWorkspaceId(TOKEN_STATUS_ACTIVE, normalizedWorkspaceId);
+            }
+            return authTokenDao.findByStatusAndCreatorAndWorkspaceId(
+                    TOKEN_STATUS_ACTIVE,
+                    getCurrentUserId(subjectSum),
+                    normalizedWorkspaceId);
+        }
         if (subjectSum.hasRole("admin")) {
             return authTokenDao.findByStatus(TOKEN_STATUS_ACTIVE);
         }
@@ -228,9 +269,25 @@ public class AccountServiceImpl implements AccountService {
         if (!subjectSum.hasRole("admin") && !StringUtils.equals(userId, token.getCreator())) {
             throw new AuthenticationException("No permission");
         }
-        tokenStatusCache.invalidate(token.getTokenHash());
-        lastUsedWriteTimestamps.remove(token.getTokenHash());
-        authTokenDao.deleteById(id);
+        String workspaceId = AuthTokenRequestContext.currentWorkspaceId();
+        if (StringUtils.isNotBlank(workspaceId)
+                && !StringUtils.equals(
+                        AuthTokenScopes.normalizeWorkspaceId(workspaceId),
+                        AuthTokenScopes.normalizeWorkspaceId(token.getWorkspaceId()))) {
+            throw new AuthenticationException("No workspace permission");
+        }
+        if (!Byte.valueOf(TOKEN_STATUS_ACTIVE).equals(token.getStatus())) {
+            return;
+        }
+        String tokenHash = token.getTokenHash();
+        if (StringUtils.isNotBlank(tokenHash)) {
+            invalidateTokenStatusCache(tokenHash);
+            lastUsedWriteTimestamps.remove(tokenHash);
+        }
+        token.setStatus(TOKEN_STATUS_REVOKED);
+        token.setRevokedBy(userId);
+        token.setRevokedTime(LocalDateTime.now());
+        authTokenDao.save(token);
     }
 
     @Override
@@ -244,6 +301,49 @@ public class AccountServiceImpl implements AccountService {
         return null;
     }
 
+    @Override
+    public String checkTokenStatus(String tokenValue, String requiredScope) {
+        String tokenHash = CryptoUtils.sha256Hex(tokenValue);
+        String normalizedRequiredScope = AuthTokenScopes.normalizeRequiredScope(requiredScope);
+        Set<String> allowedScopes = AuthTokenScopes.allowedTokenScopesFor(normalizedRequiredScope);
+        Boolean active = tokenStatusCache.get(statusCacheKey(tokenHash, normalizedRequiredScope),
+            cacheKey -> authTokenDao.existsByTokenHashAndStatusAndTokenScopeIn(
+                tokenHash, TOKEN_STATUS_ACTIVE, allowedScopes));
+        if (Boolean.TRUE.equals(active)) {
+            return null;
+        }
+        if (authTokenDao.existsByTokenHashAndStatus(tokenHash, TOKEN_STATUS_ACTIVE)) {
+            return "Token scope is not allowed";
+        }
+        return "Token has been revoked";
+    }
+
+    @Override
+    public String checkTokenStatus(String tokenValue, String requiredScope, String workspaceId) {
+        if (StringUtils.isBlank(workspaceId)) {
+            return checkTokenStatus(tokenValue, requiredScope);
+        }
+        String tokenHash = CryptoUtils.sha256Hex(tokenValue);
+        String normalizedRequiredScope = AuthTokenScopes.normalizeRequiredScope(requiredScope);
+        String normalizedWorkspaceId = AuthTokenScopes.normalizeWorkspaceId(workspaceId);
+        Set<String> allowedScopes = AuthTokenScopes.allowedTokenScopesFor(normalizedRequiredScope);
+        Boolean active = tokenStatusCache.get(
+            statusCacheKey(tokenHash, normalizedRequiredScope, normalizedWorkspaceId),
+            cacheKey -> authTokenDao.existsByTokenHashAndStatusAndTokenScopeInAndWorkspaceId(
+                tokenHash, TOKEN_STATUS_ACTIVE, allowedScopes, normalizedWorkspaceId));
+        if (Boolean.TRUE.equals(active)) {
+            return null;
+        }
+        if (authTokenDao.existsByTokenHashAndStatusAndTokenScopeIn(
+            tokenHash, TOKEN_STATUS_ACTIVE, allowedScopes)) {
+            return "Token workspace is not allowed";
+        }
+        if (authTokenDao.existsByTokenHashAndStatus(tokenHash, TOKEN_STATUS_ACTIVE)) {
+            return "Token scope is not allowed";
+        }
+        return "Token has been revoked";
+    }
+
     @Override
     public String checkManagedTokenAccess(String userId, List<String> claimedRoles) {
         if (StringUtils.isBlank(userId)) {
@@ -282,9 +382,15 @@ public class AccountServiceImpl implements AccountService {
      *
      * @param expireSeconds optional expiration in seconds, null means never expire
      */
-    private String issueApiToken(String userId, List<String> roles, Long expireSeconds) {
-        Map<String, Object> customClaimMap = new HashMap<>(2);
+    private String issueApiToken(String userId,
+                                 List<String> roles,
+                                 Long expireSeconds,
+                                 String tokenScope,
+                                 String workspaceId) {
+        Map<String, Object> customClaimMap = new HashMap<>(4);
         customClaimMap.put(CLAIM_MANAGED, true);
+        customClaimMap.put(AuthTokenScopes.CLAIM_TOKEN_SCOPE, tokenScope);
+        customClaimMap.put(AuthTokenScopes.CLAIM_WORKSPACE_ID, workspaceId);
         long effectiveExpire = (expireSeconds != null && expireSeconds > 0) ? expireSeconds : NON_EXPIRING_TOKEN_SECONDS;
         return JsonWebTokenUtil.issueJwt(userId, effectiveExpire, roles, customClaimMap);
     }
@@ -311,4 +417,17 @@ public class AccountServiceImpl implements AccountService {
         Object principal = subjectSum.getPrincipal();
         return principal == null ? null : String.valueOf(principal);
     }
+
+    private void invalidateTokenStatusCache(String tokenHash) {
+        tokenStatusCache.invalidate(tokenHash);
+        tokenStatusCache.asMap().keySet().removeIf(key -> key.startsWith(tokenHash + ":"));
+    }
+
+    private String statusCacheKey(String tokenHash, String requiredScope) {
+        return tokenHash + ":" + requiredScope;
+    }
+
+    private String statusCacheKey(String tokenHash, String requiredScope, String workspaceId) {
+        return tokenHash + ":" + requiredScope + ":" + workspaceId;
+    }
 }
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java
index 36414d88f2..c8ccbe9918 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.manager.config;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -33,7 +34,10 @@ import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.util.List;
 import org.apache.hertzbeat.common.constants.NetworkConstants;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.manager.service.AccountService;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -63,6 +67,14 @@ class ApiTokenValidationFilterTest {
     @BeforeEach
     void setUp() {
         filter = new ApiTokenValidationFilter(accountService);
+        AuthTokenRequestContext.clear();
+        lenient().when(request.getHeader(AuthTokenScopes.WORKSPACE_ID_HEADER)).thenReturn(null);
+        lenient().when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_WORKSPACE_ID)).thenReturn(null);
+    }
+
+    @AfterEach
+    void tearDown() {
+        AuthTokenRequestContext.clear();
     }
 
     @Test
@@ -101,11 +113,30 @@ class ApiTokenValidationFilterTest {
         }
     }
 
+    @Test
+    void testUiSessionWorkspaceClaimBindsRequestContextWithoutManagedValidation() throws Exception {
+        SubjectSum subject = mockSubject();
+        when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_WORKSPACE_ID)).thenReturn("team-a");
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+            org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object()));
+            org.junit.jupiter.api.Assertions.assertEquals("team-a", AuthTokenRequestContext.currentWorkspaceId());
+            verify(accountService, never()).checkTokenStatus(any());
+
+            filter.afterCompletion(request, response, new Object(), null);
+            org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentWorkspaceId());
+        }
+    }
+
     @Test
     void testManagedTokenActivePassesThrough() throws Exception {
         String managedToken = "managed-token";
         when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken);
-        when(accountService.checkTokenStatus(managedToken)).thenReturn(null);
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn("/api/monitor");
+        when(accountService.checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN)).thenReturn(null);
         when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null);
         doNothing().when(accountService).touchTokenLastUsedTime(managedToken);
         SubjectSum subject = mockManagedSubjectWithClaims();
@@ -114,17 +145,79 @@ class ApiTokenValidationFilterTest {
             mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
 
             org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object()));
-            verify(accountService).checkTokenStatus(managedToken);
+            verify(accountService).checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN);
             verify(accountService).checkManagedTokenAccess("admin", List.of("admin"));
             verify(accountService).touchTokenLastUsedTime(managedToken);
         }
     }
 
+    @Test
+    void testManagedTokenReadRequestRequiresReadonlyScope() throws Exception {
+        String managedToken = "managed-token";
+        when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken);
+        when(request.getMethod()).thenReturn("GET");
+        when(request.getRequestURI()).thenReturn("/api/monitor");
+        when(accountService.checkTokenStatus(managedToken, AuthTokenScopes.READONLY_QUERY)).thenReturn(null);
+        when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null);
+        doNothing().when(accountService).touchTokenLastUsedTime(managedToken);
+        SubjectSum subject = mockManagedSubjectWithClaims();
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+            org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object()));
+            verify(accountService).checkTokenStatus(managedToken, AuthTokenScopes.READONLY_QUERY);
+        }
+    }
+
+    @Test
+    void testManagedTokenOtlpRequestRequiresIngestScope() throws Exception {
+        String managedToken = "managed-token";
+        when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken);
+        when(request.getRequestURI()).thenReturn("/api/otlp/v1/metrics");
+        when(accountService.checkTokenStatus(managedToken, AuthTokenScopes.OTLP_INGEST)).thenReturn(null);
+        when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null);
+        doNothing().when(accountService).touchTokenLastUsedTime(managedToken);
+        SubjectSum subject = mockManagedSubjectWithClaims();
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+            org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object()));
+            verify(accountService).checkTokenStatus(managedToken, AuthTokenScopes.OTLP_INGEST);
+        }
+    }
+
+    @Test
+    void testManagedTokenWorkspaceHeaderRequiresWorkspaceBoundary() throws Exception {
+        String managedToken = "managed-token";
+        when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken);
+        when(request.getHeader(AuthTokenScopes.WORKSPACE_ID_HEADER)).thenReturn("prod-west");
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn("/api/monitor");
+        when(accountService.checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN, "prod-west")).thenReturn(null);
+        when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null);
+        doNothing().when(accountService).touchTokenLastUsedTime(managedToken);
+        SubjectSum subject = mockManagedSubjectWithClaims();
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+            org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object()));
+            verify(accountService).checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN, "prod-west");
+            org.junit.jupiter.api.Assertions.assertEquals("prod-west", AuthTokenRequestContext.currentWorkspaceId());
+            filter.afterCompletion(request, response, new Object(), null);
+            org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentWorkspaceId());
+        }
+    }
+
     @Test
     void testManagedTokenRevokedRejected() throws Exception {
         String revokedToken = "revoked-token";
         when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + revokedToken);
-        when(accountService.checkTokenStatus(revokedToken)).thenReturn("Token has been revoked");
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn("/api/monitor");
+        when(accountService.checkTokenStatus(revokedToken, AuthTokenScopes.API_ADMIN)).thenReturn("Token has been revoked");
 
         StringWriter stringWriter = new StringWriter();
         PrintWriter printWriter = new PrintWriter(stringWriter);
@@ -142,7 +235,9 @@ class ApiTokenValidationFilterTest {
     @Test
     void testManagedTokenStatusCheckFailureRejectsRequest() throws Exception {
         when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer managed-token");
-        when(accountService.checkTokenStatus("managed-token")).thenThrow(new RuntimeException("DB down"));
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn("/api/monitor");
+        when(accountService.checkTokenStatus("managed-token", AuthTokenScopes.API_ADMIN)).thenThrow(new RuntimeException("DB down"));
 
         StringWriter stringWriter = new StringWriter();
         PrintWriter printWriter = new PrintWriter(stringWriter);
@@ -162,7 +257,9 @@ class ApiTokenValidationFilterTest {
     void testManagedTokenOutdatedRolesRejected() throws Exception {
         String managedToken = "managed-token";
         when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken);
-        when(accountService.checkTokenStatus(managedToken)).thenReturn(null);
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn("/api/monitor");
+        when(accountService.checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN)).thenReturn(null);
         when(accountService.checkManagedTokenAccess("admin", List.of("admin")))
                 .thenReturn("Token permissions are outdated");
 
@@ -196,7 +293,9 @@ class ApiTokenValidationFilterTest {
     void testTouchLastUsedTimeFailureDoesNotRejectRequest() throws Exception {
         String managedToken = "managed-token";
         when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken);
-        when(accountService.checkTokenStatus(managedToken)).thenReturn(null);
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn("/api/monitor");
+        when(accountService.checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN)).thenReturn(null);
         when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null);
         // touchTokenLastUsedTime throws exception
         org.mockito.Mockito.doThrow(new RuntimeException("DB error"))
@@ -224,7 +323,9 @@ class ApiTokenValidationFilterTest {
     void testManagedTokenAccountValidationFailureRejectsRequest() throws Exception {
         String managedToken = "managed-token";
         when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken);
-        when(accountService.checkTokenStatus(managedToken)).thenReturn(null);
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn("/api/monitor");
+        when(accountService.checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN)).thenReturn(null);
         when(accountService.checkManagedTokenAccess("admin", List.of("admin")))
                 .thenThrow(new RuntimeException("account store unavailable"));
 
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/OtlpRouteAuthorizationConfigTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/OtlpRouteAuthorizationConfigTest.java
new file mode 100644
index 0000000000..328b2b0d98
--- /dev/null
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/OtlpRouteAuthorizationConfigTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.hertzbeat.manager.config;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+/**
+ * Release-readiness contracts for shipped OTLP route authorization templates.
+ */
+class OtlpRouteAuthorizationConfigTest {
+
+    private static final List<String> REQUIRED_OTLP_RESOURCE_RULES = List.of(
+            "  - /api/otlp/**===post===[admin,user]",
+            "  - /api/logs/otlp/**===post===[admin,user]"
+    );
+    private static final List<String> SURENESS_CONFIGS = List.of(
+            "hertzbeat-startup/src/main/resources/sureness.yml",
+            "hertzbeat-manager/src/test/resources/sureness.yml",
+            "hertzbeat-e2e/hertzbeat-observability-e2e/src/test/resources/sureness.yml",
+            "script/sureness.yml",
+            "script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml",
+            "script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml",
+            "script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml",
+            "script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml",
+            "script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml"
+    );
+
+    @Test
+    void shippedSurenessConfigsRouteOtlpPostThroughManagedTokenBoundary() {
+        List<Executable> checks = new ArrayList<>();
+        for (String config : SURENESS_CONFIGS) {
+            checks.add(() -> assertOtlpRules(config));
+        }
+        assertAll(checks);
+    }
+
+    private static void assertOtlpRules(String config) throws IOException {
+        List<String> lines = Files.readAllLines(repoRoot().resolve(config));
+        for (String rule : REQUIRED_OTLP_RESOURCE_RULES) {
+            assertTrue(lines.contains(rule),
+                    () -> config + " must include OTLP managed-token route rule " + rule.trim());
+        }
+    }
+
+    private static Path repoRoot() {
+        Path current = Paths.get("").toAbsolutePath();
+        while (current != null && !Files.exists(current.resolve("hertzbeat-manager/pom.xml"))) {
+            current = current.getParent();
+        }
+        if (current == null) {
+            throw new IllegalStateException("Cannot locate HertzBeat repository root");
+        }
+        return current;
+    }
+}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/AuthTokenControllerTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/AuthTokenControllerTest.java
index 58910869dc..34e2a3de20 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/AuthTokenControllerTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/AuthTokenControllerTest.java
@@ -33,6 +33,7 @@ import java.time.LocalDateTime;
 import java.util.List;
 import org.apache.hertzbeat.common.constants.CommonConstants;
 import org.apache.hertzbeat.common.entity.manager.AuthToken;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.manager.service.AccountService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -69,7 +70,8 @@ class AuthTokenControllerTest {
 
         try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
             mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
-            when(accountService.generateToken(eq("my-token"), eq(3600L))).thenReturn("generated-jwt-token");
+            when(accountService.generateToken(eq("my-token"), eq(3600L), eq(AuthTokenScopes.API_ADMIN)))
+                    .thenReturn("generated-jwt-token");
 
             this.mockMvc.perform(MockMvcRequestBuilders.post("/api/account/token/generate")
                             .param("name", "my-token")
@@ -86,7 +88,8 @@ class AuthTokenControllerTest {
 
         try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
             mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
-            when(accountService.generateToken(any(), any())).thenReturn("generated-jwt-token");
+            when(accountService.generateToken(any(), any(), eq(AuthTokenScopes.API_ADMIN)))
+                    .thenReturn("generated-jwt-token");
 
             this.mockMvc.perform(MockMvcRequestBuilders.post("/api/account/token/generate"))
                     .andExpect(status().isOk())
@@ -95,6 +98,49 @@ class AuthTokenControllerTest {
         }
     }
 
+    @Test
+    void testGenerateTokenWithScope() throws Exception {
+        SubjectSum subjectSum = mockAdminSubject();
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+            when(accountService.generateToken(eq("otlp"), eq(3600L), eq(AuthTokenScopes.OTLP_INGEST)))
+                    .thenReturn("generated-jwt-token");
+
+            this.mockMvc.perform(MockMvcRequestBuilders.post("/api/account/token/generate")
+                            .param("name", "otlp")
+                            .param("expireSeconds", "3600")
+                            .param("scope", AuthTokenScopes.OTLP_INGEST))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
+                    .andExpect(jsonPath("$.data.token").value("generated-jwt-token"));
+        }
+    }
+
+    @Test
+    void testGenerateTokenWithWorkspaceBoundary() throws Exception {
+        SubjectSum subjectSum = mockAdminSubject();
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+            when(accountService.generateToken(
+                    eq("otlp"),
+                    eq(3600L),
+                    eq(AuthTokenScopes.OTLP_INGEST),
+                    eq("prod-west")))
+                    .thenReturn("generated-jwt-token");
+
+            this.mockMvc.perform(MockMvcRequestBuilders.post("/api/account/token/generate")
+                            .param("name", "otlp")
+                            .param("expireSeconds", "3600")
+                            .param("scope", AuthTokenScopes.OTLP_INGEST)
+                            .param("workspaceId", "prod-west"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
+                    .andExpect(jsonPath("$.data.token").value("generated-jwt-token"));
+        }
+    }
+
     @Test
     void testGenerateTokenNoLogin() throws Exception {
         try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/AccountServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/AccountServiceTest.java
index 5f1d95d811..1538472b14 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/AccountServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/AccountServiceTest.java
@@ -27,6 +27,8 @@ import com.usthe.sureness.util.SurenessContextHolder;
 import io.jsonwebtoken.Claims;
 import io.jsonwebtoken.MalformedJwtException;
 import org.apache.hertzbeat.common.entity.manager.AuthToken;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.common.util.JsonUtil;
 import org.apache.hertzbeat.manager.dao.AuthTokenDao;
 import org.apache.hertzbeat.manager.pojo.dto.LoginDto;
@@ -44,6 +46,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -53,6 +56,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -116,6 +120,9 @@ class AccountServiceTest {
         assertNotNull(response.get("refreshToken"));
         assertNotNull(response.get("role"));
         assertEquals(JsonUtil.toJson(roles), response.get("role"));
+        Claims accessClaims = JsonWebTokenUtil.parseJwt(response.get("token"));
+        assertEquals(AuthTokenScopes.UI_SESSION, accessClaims.get(AuthTokenScopes.CLAIM_TOKEN_SCOPE, String.class));
+        assertEquals(AuthTokenScopes.DEFAULT_WORKSPACE_ID, accessClaims.get(AuthTokenScopes.CLAIM_WORKSPACE_ID, String.class));
 
     }
 
@@ -160,6 +167,8 @@ class AccountServiceTest {
         Claims accessClaims = JsonWebTokenUtil.parseJwt(response.getToken());
         Claims refreshClaims = JsonWebTokenUtil.parseJwt(response.getRefreshToken());
         assertNull(accessClaims.get("refresh", Boolean.class));
+        assertEquals(AuthTokenScopes.UI_SESSION, accessClaims.get(AuthTokenScopes.CLAIM_TOKEN_SCOPE, String.class));
+        assertEquals(AuthTokenScopes.DEFAULT_WORKSPACE_ID, accessClaims.get(AuthTokenScopes.CLAIM_WORKSPACE_ID, String.class));
         assertEquals(Boolean.TRUE, refreshClaims.get("refresh", Boolean.class));
     }
 
@@ -277,10 +286,81 @@ class AccountServiceTest {
             assertNotNull(saved.getTokenMask());
             assertEquals((byte) 0, saved.getStatus());
             assertEquals(identifier, saved.getCreator());
+            assertEquals(AuthTokenScopes.API_ADMIN, saved.getTokenScope());
             assertNull(saved.getExpireTime());
         }
     }
 
+    @Test
+    void testGenerateTokenPersistsRequestedScope() throws Exception {
+        SurenessAccount account = buildActiveAccount();
+        when(accountProvider.loadAccount(identifier)).thenReturn(account);
+        when(authTokenDao.save(any(AuthToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
+        SubjectSum subjectSum = mockAdminSubject(identifier);
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+            String token = accountService.generateToken("OTLP ingest", null, AuthTokenScopes.OTLP_INGEST);
+
+            Claims claims = JsonWebTokenUtil.parseJwt(token);
+            assertEquals(AuthTokenScopes.OTLP_INGEST, claims.get(AuthTokenScopes.CLAIM_TOKEN_SCOPE, String.class));
+            ArgumentCaptor<AuthToken> captor = ArgumentCaptor.forClass(AuthToken.class);
+            verify(authTokenDao).save(captor.capture());
+            assertEquals(AuthTokenScopes.OTLP_INGEST, captor.getValue().getTokenScope());
+        }
+    }
+
+    @Test
+    void testGenerateTokenPersistsWorkspaceBoundary() throws Exception {
+        SurenessAccount account = buildActiveAccount();
+        when(accountProvider.loadAccount(identifier)).thenReturn(account);
+        when(authTokenDao.save(any(AuthToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
+        SubjectSum subjectSum = mockAdminSubject(identifier);
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+            String token = accountService.generateToken(
+                    "prod ingest",
+                    null,
+                    AuthTokenScopes.OTLP_INGEST,
+                    "prod-west"
+            );
+
+            Claims claims = JsonWebTokenUtil.parseJwt(token);
+            assertEquals("prod-west", claims.get(AuthTokenScopes.CLAIM_WORKSPACE_ID, String.class));
+            ArgumentCaptor<AuthToken> captor = ArgumentCaptor.forClass(AuthToken.class);
+            verify(authTokenDao).save(captor.capture());
+            assertEquals("prod-west", captor.getValue().getWorkspaceId());
+        }
+    }
+
+    @Test
+    void testGenerateTokenRejectsScopeQuotaExceeded() {
+        SurenessAccount account = buildActiveAccount();
+        when(accountProvider.loadAccount(identifier)).thenReturn(account);
+        when(authTokenDao.countByStatusAndCreatorAndTokenScopeAndWorkspaceId(
+                eq((byte) 0),
+                eq(identifier),
+                eq(AuthTokenScopes.OTLP_INGEST),
+                eq(AuthTokenScopes.DEFAULT_WORKSPACE_ID)))
+                .thenReturn(20L);
+        SubjectSum subjectSum = mockAdminSubject(identifier);
+
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+            AuthenticationException exception = Assertions.assertThrows(
+                    AuthenticationException.class,
+                    () -> accountService.generateToken("extra-otlp", null, AuthTokenScopes.OTLP_INGEST)
+            );
+
+            assertEquals("Token quota exceeded", exception.getMessage());
+            verify(authTokenDao, never()).save(any(AuthToken.class));
+        }
+    }
+
     @Test
     void testGenerateTokenNullName() throws Exception {
         SurenessAccount account = buildActiveAccount();
@@ -375,6 +455,52 @@ class AccountServiceTest {
         }
     }
 
+    @Test
+    void testListTokensForAdminUsesWorkspaceContext() {
+        List<AuthToken> expected = List.of(
+                AuthToken.builder().id(1L).name("Token1").workspaceId("team-a").build()
+        );
+        when(authTokenDao.findByStatusAndWorkspaceId((byte) 0, "team-a")).thenReturn(expected);
+        SubjectSum subjectSum = mockAdminSubject(identifier);
+
+        AuthTokenRequestContext.bindWorkspaceId("team-a");
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+            List<AuthToken> result = accountService.listTokens();
+
+            assertEquals(1, result.size());
+            assertEquals("Token1", result.get(0).getName());
+            verify(authTokenDao).findByStatusAndWorkspaceId((byte) 0, "team-a");
+            verify(authTokenDao, never()).findByStatus((byte) 0);
+        } finally {
+            AuthTokenRequestContext.clear();
+        }
+    }
+
+    @Test
+    void testListTokensForCurrentUserUsesWorkspaceContext() {
+        List<AuthToken> expected = List.of(
+                AuthToken.builder().id(1L).name("Token1").creator("tom").workspaceId("team-a").build()
+        );
+        when(authTokenDao.findByStatusAndCreatorAndWorkspaceId((byte) 0, "tom", "team-a")).thenReturn(expected);
+        SubjectSum subjectSum = mockUserSubject("tom");
+
+        AuthTokenRequestContext.bindWorkspaceId("team-a");
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+            List<AuthToken> result = accountService.listTokens();
+
+            assertEquals(1, result.size());
+            assertEquals("Token1", result.get(0).getName());
+            verify(authTokenDao).findByStatusAndCreatorAndWorkspaceId((byte) 0, "tom", "team-a");
+            verify(authTokenDao, never()).findByStatusAndCreator((byte) 0, "tom");
+        } finally {
+            AuthTokenRequestContext.clear();
+        }
+    }
+
     @Test
     void testDeleteTokenInvalidatesCache() throws Exception {
         AuthToken token = AuthToken.builder().id(1L).tokenHash("hash123").creator(identifier).build();
@@ -387,7 +513,13 @@ class AccountServiceTest {
             accountService.deleteToken(1L);
         }
 
-        verify(authTokenDao).deleteById(1L);
+        ArgumentCaptor<AuthToken> captor = ArgumentCaptor.forClass(AuthToken.class);
+        verify(authTokenDao).save(captor.capture());
+        AuthToken revoked = captor.getValue();
+        assertEquals((byte) 1, revoked.getStatus());
+        assertEquals(identifier, revoked.getRevokedBy());
+        assertNotNull(revoked.getRevokedTime());
+        verify(authTokenDao, never()).deleteById(1L);
     }
 
     @Test
@@ -403,6 +535,29 @@ class AccountServiceTest {
         }
     }
 
+    @Test
+    void testDeleteTokenRejectsWorkspaceMismatch() {
+        AuthToken token = AuthToken.builder()
+                .id(1L)
+                .tokenHash("hash123")
+                .creator(identifier)
+                .workspaceId("team-b")
+                .status((byte) 0)
+                .build();
+        when(authTokenDao.findById(1L)).thenReturn(Optional.of(token));
+        SubjectSum subjectSum = mockAdminSubject(identifier);
+
+        AuthTokenRequestContext.bindWorkspaceId("team-a");
+        try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+            mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+            Assertions.assertThrows(AuthenticationException.class, () -> accountService.deleteToken(1L));
+            verify(authTokenDao, never()).save(any(AuthToken.class));
+        } finally {
+            AuthTokenRequestContext.clear();
+        }
+    }
+
     @Test
     void testCheckTokenStatusActive() throws Exception {
         SurenessAccount account = buildActiveAccount();
@@ -415,14 +570,51 @@ class AccountServiceTest {
             mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
 
             String token = accountService.generateToken("test", null);
-            when(authTokenDao.existsByTokenHashAndStatus(any(String.class), eq((byte) 0))).thenReturn(true);
+            when(authTokenDao.existsByTokenHashAndStatusAndTokenScopeIn(
+                    any(String.class),
+                    eq((byte) 0),
+                    eq(Set.of(AuthTokenScopes.API_ADMIN))))
+                    .thenReturn(true);
 
-            String result = accountService.checkTokenStatus(token);
+            String result = accountService.checkTokenStatus(token, AuthTokenScopes.API_ADMIN);
 
             assertNull(result);
         }
     }
 
+    @Test
+    void testCheckTokenStatusRejectsScopeMismatch() {
+        when(authTokenDao.existsByTokenHashAndStatusAndTokenScopeIn(
+                any(String.class),
+                eq((byte) 0),
+                eq(Set.of(AuthTokenScopes.API_ADMIN, AuthTokenScopes.READONLY_QUERY))))
+                .thenReturn(false);
+        when(authTokenDao.existsByTokenHashAndStatus(any(String.class), eq((byte) 0))).thenReturn(true);
+
+        String result = accountService.checkTokenStatus("readonly-token", AuthTokenScopes.READONLY_QUERY);
+
+        assertEquals("Token scope is not allowed", result);
+    }
+
+    @Test
+    void testCheckTokenStatusRejectsWorkspaceMismatch() {
+        when(authTokenDao.existsByTokenHashAndStatusAndTokenScopeInAndWorkspaceId(
+                any(String.class),
+                eq((byte) 0),
+                eq(Set.of(AuthTokenScopes.API_ADMIN, AuthTokenScopes.OTLP_INGEST)),
+                eq("prod-west")))
+                .thenReturn(false);
+        when(authTokenDao.existsByTokenHashAndStatusAndTokenScopeIn(
+                any(String.class),
+                eq((byte) 0),
+                eq(Set.of(AuthTokenScopes.API_ADMIN, AuthTokenScopes.OTLP_INGEST))))
+                .thenReturn(true);
+
+        String result = accountService.checkTokenStatus("prod-token", AuthTokenScopes.OTLP_INGEST, "prod-west");
+
+        assertEquals("Token workspace is not allowed", result);
+    }
+
     @Test
     void testCheckTokenStatusRevoked() {
         when(authTokenDao.existsByTokenHashAndStatus(any(String.class), eq((byte) 0))).thenReturn(false);
@@ -529,7 +721,12 @@ class AccountServiceTest {
         // After revoke, the next check should hit DB again (cache invalidated for that hash)
         // Note: the tokenHash in DB differs from sha256(tokenValue), so this tests cache invalidation path
         verify(authTokenDao).findById(1L);
-        verify(authTokenDao).deleteById(1L);
+        ArgumentCaptor<AuthToken> captor = ArgumentCaptor.forClass(AuthToken.class);
+        verify(authTokenDao).save(captor.capture());
+        assertEquals((byte) 1, captor.getValue().getStatus());
+        assertEquals(identifier, captor.getValue().getRevokedBy());
+        assertNotNull(captor.getValue().getRevokedTime());
+        verify(authTokenDao, never()).deleteById(1L);
     }
 
     private SurenessAccount buildActiveAccount() {
diff --git a/hertzbeat-manager/src/test/resources/sureness.yml b/hertzbeat-manager/src/test/resources/sureness.yml
index 3368fe7018..6a8672f3c5 100644
--- a/hertzbeat-manager/src/test/resources/sureness.yml
+++ b/hertzbeat-manager/src/test/resources/sureness.yml
@@ -22,13 +22,25 @@
 resourceRole:
   - /api/account/auth/refresh===post===[admin,user,guest]
   - /api/apps/**===get===[admin,user,guest]
+  - /api/monitor===get===[admin,user,guest]
   - /api/monitor/**===get===[admin,user,guest]
+  - /api/monitor===post===[admin,user]
   - /api/monitor/**===post===[admin,user]
+  - /api/monitor===put===[admin,user]
   - /api/monitor/**===put===[admin,user]
-  - /api/monitor/**===delete==[admin]
+  - /api/monitor===delete===[admin]
+  - /api/monitor/**===delete===[admin]
+  - /api/monitors===get===[admin,user,guest]
+  - /api/monitors/export===get===[admin]
+  - /api/monitors/export/all===get===[admin]
+  - /api/monitors/manage===get===[admin,user,guest]
+  - /api/monitors/manage===post===[admin,user]
+  - /api/monitors/manage===put===[admin,user]
   - /api/monitors/**===get===[admin,user,guest]
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
+  - /api/monitors===delete===[admin]
+  - /api/monitors/manage===delete===[admin]
   - /api/monitors/**===delete===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
@@ -63,10 +75,17 @@ resourceRole:
   - /api/grafana/**===put===[admin,user]
   - /api/grafana/**===delete===[admin]
   - /api/ingestion/otlp/**===get===[admin,user,guest]
+  - /api/otlp/**===post===[admin,user]
+  - /api/logs/otlp/**===post===[admin,user]
   - /api/bulletin/**===get===[admin,user,guest]
   - /api/bulletin/**===post===[admin,user]
   - /api/bulletin/**===put===[admin,user]
   - /api/bulletin/**===delete===[admin]
+  - /api/actions/approval-drafts===get===[admin,user]
+  - /api/actions/approval-drafts===post===[admin,user]
+  - /api/actions/approval-drafts/**===post===[admin,user]
+  - /api/actions/catalog===get===[admin,user]
+  - /api/actions/catalog===post===[admin]
   - /api/sse/**===get===[admin,user]
   - /api/sse/**===post===[admin,user]
   - /api/chat/**===get===[admin,user]
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/config/OpenTelemetryConfig.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/config/OpenTelemetryConfig.java
index d6da18e1b4..7d7181e893 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/config/OpenTelemetryConfig.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/config/OpenTelemetryConfig.java
@@ -26,6 +26,7 @@ import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder;
 import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider;
 import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor;
 import io.opentelemetry.sdk.resources.Resource;
+import java.nio.charset.StandardCharsets;
 import java.util.Base64;
 import java.util.Collections;
 import java.util.HashMap;
@@ -33,6 +34,7 @@ import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.forwarder.GreptimeOtlpForwarder;
 import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
 import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -51,14 +53,19 @@ public class OpenTelemetryConfig {
     private static final String DEFAULT_TRACES_TABLE_NAME = "hzb_traces";
     private static final String GREPTIME_DB_NAME_HEADER = "X-Greptime-DB-Name";
     private static final String GREPTIME_LOG_TABLE_NAME_HEADER = "X-Greptime-Log-Table-Name";
+    private static final String GREPTIME_LOG_PIPELINE_NAME_HEADER = "X-Greptime-Log-Pipeline-Name";
     private static final String GREPTIME_TRACE_TABLE_NAME_HEADER = "X-Greptime-Trace-Table-Name";
     private static final String GREPTIME_PIPELINE_NAME_HEADER = "X-Greptime-Pipeline-Name";
+    private static final String GREPTIME_LOGS_PATH = "/v1/otlp/v1/logs";
+    private static final String GREPTIME_TRACES_PATH = "/v1/otlp/v1/traces";
 
     private void addAuthenticationHeaders(Map<String, String> headers, GreptimeProperties greptimeProps) {
-        if (greptimeProps != null && StringUtils.isNotBlank(greptimeProps.username())
-                && StringUtils.isNotBlank(greptimeProps.password())) {
-            String credentials = greptimeProps.username() + ":" + greptimeProps.password();
-            String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
+        String username = greptimeProps == null ? null : StringUtils.trimToNull(greptimeProps.username());
+        String password = greptimeProps == null ? null : StringUtils.trimToNull(greptimeProps.password());
+        if (username != null && password != null) {
+            String credentials = username + ":" + password;
+            String encodedCredentials = Base64.getEncoder()
+                    .encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
             headers.put("Authorization", "Basic " + encodedCredentials);
             log.debug("Added Basic Authentication header for GreptimeDB.");
         } else {
@@ -68,15 +75,16 @@ public class OpenTelemetryConfig {
 
     private Map<String, String> buildGreptimeOtlpLogHeaders(GreptimeProperties greptimeProps) {
         Map<String, String> headers = new HashMap<>();
-        headers.put(GREPTIME_DB_NAME_HEADER, DEFAULT_GREPTIME_DB_NAME);
+        headers.put(GREPTIME_DB_NAME_HEADER, database(greptimeProps));
         headers.put(GREPTIME_LOG_TABLE_NAME_HEADER, WarehouseConstants.LOG_TABLE_NAME);
+        headers.put(GREPTIME_LOG_PIPELINE_NAME_HEADER, GreptimeOtlpForwarder.LOG_PIPELINE_NAME);
         addAuthenticationHeaders(headers, greptimeProps);
         return Collections.unmodifiableMap(headers);
     }
 
     private Map<String, String> buildGreptimeOtlpTraceHeaders(GreptimeProperties greptimeProps) {
         Map<String, String> headers = new HashMap<>();
-        headers.put(GREPTIME_DB_NAME_HEADER, DEFAULT_GREPTIME_DB_NAME);
+        headers.put(GREPTIME_DB_NAME_HEADER, database(greptimeProps));
         headers.put(GREPTIME_TRACE_TABLE_NAME_HEADER, DEFAULT_TRACES_TABLE_NAME);
         headers.put(CONTENT_TYPE, "application/x-protobuf");
         headers.put(GREPTIME_PIPELINE_NAME_HEADER, "greptime_trace_v1");
@@ -84,6 +92,17 @@ public class OpenTelemetryConfig {
         return Collections.unmodifiableMap(headers);
     }
 
+    private String database(GreptimeProperties greptimeProps) {
+        return greptimeProps == null
+                ? DEFAULT_GREPTIME_DB_NAME
+                : StringUtils.defaultIfBlank(StringUtils.trim(greptimeProps.database()), DEFAULT_GREPTIME_DB_NAME);
+    }
+
+    private String greptimeOtlpEndpoint(GreptimeProperties greptimeProps, String path) {
+        String endpoint = greptimeProps == null ? null : greptimeProps.httpEndpoint();
+        return StringUtils.stripEnd(StringUtils.trimToEmpty(endpoint), "/") + path;
+    }
+
     @Bean
     public AutoConfigurationCustomizerProvider defaultOtelCustomizer() {
         log.info("Applying default OpenTelemetry SDK customizations (logs & traces only).");
@@ -113,7 +132,7 @@ public class OpenTelemetryConfig {
                     return newProperties;
                 })
                 .addSpanExporterCustomizer((originalSpanExporter, configProperties) -> {
-                    String traceEndpoint = greptimeProperties.httpEndpoint() + "/v1/otlp/v1/traces";
+                    String traceEndpoint = greptimeOtlpEndpoint(greptimeProperties, GREPTIME_TRACES_PATH);
                     log.info("Configuring OtlpHttpSpanExporter for GreptimeDB. Endpoint: {}", traceEndpoint);
                     OtlpHttpSpanExporterBuilder httpExporterBuilder = OtlpHttpSpanExporter.builder()
                             .setEndpoint(traceEndpoint)
@@ -124,7 +143,7 @@ public class OpenTelemetryConfig {
                 .addLoggerProviderCustomizer((sdkLoggerProviderBuilder, configProperties) -> {
                     log.info("Customizing SdkLoggerProviderBuilder for GreptimeDB logs.");
                     OtlpHttpLogRecordExporter logExporter = OtlpHttpLogRecordExporter.builder()
-                            .setEndpoint(greptimeProperties.httpEndpoint() + "/v1/otlp/v1/logs")
+                            .setEndpoint(greptimeOtlpEndpoint(greptimeProperties, GREPTIME_LOGS_PATH))
                             .setHeaders(() -> buildGreptimeOtlpLogHeaders(greptimeProperties))
                             .setTimeout(10000, TimeUnit.MILLISECONDS)
                             .build();
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/adapter/OtlpLogProtocolAdapter.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/adapter/OtlpLogProtocolAdapter.java
index 0614801bde..2ffa688246 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/adapter/OtlpLogProtocolAdapter.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/adapter/OtlpLogProtocolAdapter.java
@@ -33,7 +33,9 @@ import lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.common.entity.log.LogEntry;
 import org.apache.hertzbeat.common.entity.observability.TelemetryIntakeSignalEvent;
 import org.apache.hertzbeat.common.queue.CommonDataQueue;
+import org.apache.hertzbeat.observability.ingestion.redaction.OtlpIngestionRedactionService;
 import org.apache.hertzbeat.observability.logs.sse.LogSseManager;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.http.HttpHeaders;
 import org.springframework.stereotype.Service;
@@ -63,19 +65,31 @@ public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
     private static final String PROTOCOL_NAME = "otlp";
     private static final String CONTENT_ENCODING = "Content-Encoding";
     private static final String CONTENT_ENCODING_GZIP = "gzip";
+    private static final int OTLP_TRACE_ID_BYTES = 16;
+    private static final int OTLP_SPAN_ID_BYTES = 8;
     private static final Set<String> OTLP_HEX_ID_FIELDS = Set.of("traceId", "spanId");
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
     private final CommonDataQueue commonDataQueue;
     private final LogSseManager logSseManager;
     private final ApplicationEventPublisher applicationEventPublisher;
+    private final OtlpIngestionRedactionService redactionService;
 
+    @Autowired
     public OtlpLogProtocolAdapter(CommonDataQueue commonDataQueue,
                                   LogSseManager logSseManager,
-                                  ApplicationEventPublisher applicationEventPublisher) {
+                                  ApplicationEventPublisher applicationEventPublisher,
+                                  OtlpIngestionRedactionService redactionService) {
         this.commonDataQueue = commonDataQueue;
         this.logSseManager = logSseManager;
         this.applicationEventPublisher = applicationEventPublisher;
+        this.redactionService = redactionService;
+    }
+
+    public OtlpLogProtocolAdapter(CommonDataQueue commonDataQueue,
+                                  LogSseManager logSseManager,
+                                  ApplicationEventPublisher applicationEventPublisher) {
+        this(commonDataQueue, logSseManager, applicationEventPublisher, new OtlpIngestionRedactionService());
     }
 
     @Override
@@ -141,13 +155,29 @@ public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
         if (content == null || content.length == 0 || requestHeaders == null) {
             return content;
         }
-        String contentEncoding = requestHeaders.getFirst(CONTENT_ENCODING);
-        if (contentEncoding == null || !CONTENT_ENCODING_GZIP.equalsIgnoreCase(contentEncoding)) {
+        if (!isGzipEncoded(requestHeaders)) {
             return content;
         }
         return decompressGzip(content);
     }
 
+    private boolean isGzipContentEncoding(String contentEncoding) {
+        if (contentEncoding == null || contentEncoding.isBlank()) {
+            return false;
+        }
+        for (String encoding : contentEncoding.split(",")) {
+            if (CONTENT_ENCODING_GZIP.equalsIgnoreCase(encoding.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean isGzipEncoded(HttpHeaders requestHeaders) {
+        List<String> contentEncodings = requestHeaders == null ? null : requestHeaders.get(CONTENT_ENCODING);
+        return contentEncodings != null && contentEncodings.stream().anyMatch(this::isGzipContentEncoding);
+    }
+
     private byte[] decompressGzip(byte[] content) {
         try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content);
              GZIPInputStream gzipInputStream = new GZIPInputStream(inputStream);
@@ -213,8 +243,8 @@ public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
             .body(extractBody(logRecord.getBody()))
             .attributes(extractAttributes(logRecord.getAttributesList()))
             .droppedAttributesCount(logRecord.getDroppedAttributesCount())
-            .traceId(bytesToHex(logRecord.getTraceId().toByteArray()))
-            .spanId(bytesToHex(logRecord.getSpanId().toByteArray()))
+            .traceId(bytesToOtlpIdHex(logRecord.getTraceId().toByteArray(), OTLP_TRACE_ID_BYTES))
+            .spanId(bytesToOtlpIdHex(logRecord.getSpanId().toByteArray(), OTLP_SPAN_ID_BYTES))
             .traceFlags(logRecord.getFlags())
             .resource(resourceAttributes)
             .resourceSchemaUrl(emptyToNull(resourceSchemaUrl))
@@ -302,7 +332,8 @@ public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
             Map<String, Object> resultMap = new HashMap<>();
             for (Map.Entry<?, ?> entry : genericMap.entrySet()) {
                 if (entry.getKey() instanceof String) {
-                    resultMap.put((String) entry.getKey(), entry.getValue());
+                    String key = (String) entry.getKey();
+                    resultMap.put(key, redactionService.redactObject(key, entry.getValue()));
                 }
             }
             return resultMap;
@@ -315,7 +346,7 @@ public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
      * Extract body content from AnyValue.
      */
     private Object extractBody(AnyValue body) {
-        return extractAnyValue(body);
+        return redactionService.redactObject(null, extractAnyValue(body));
     }
 
     /**
@@ -340,7 +371,9 @@ public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
             case KVLIST_VALUE:
                 Map<String, Object> kvMap = new HashMap<>();
                 for (KeyValue kv : anyValue.getKvlistValue().getValuesList()) {
-                    kvMap.put(normalizeKey(kv.getKey()), extractAnyValue(kv.getValue()));
+                    String normalizedKey = normalizeKey(kv.getKey());
+                    kvMap.put(normalizedKey, redactionService.redactObject(normalizedKey,
+                            extractAnyValue(kv.getValue())));
                 }
                 return kvMap;
             case BYTES_VALUE:
@@ -374,6 +407,13 @@ public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
     /**
      * Convert byte array to hex string.
      */
+    private String bytesToOtlpIdHex(byte[] bytes, int expectedLength) {
+        if (bytes == null || bytes.length != expectedLength || isAllZero(bytes)) {
+            return null;
+        }
+        return bytesToHex(bytes);
+    }
+
     private String bytesToHex(byte[] bytes) {
         if (bytes == null || bytes.length == 0) {
             return null;
@@ -389,6 +429,15 @@ public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
         return hexString.toString();
     }
 
+    private boolean isAllZero(byte[] bytes) {
+        for (byte value : bytes) {
+            if (value != 0) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     private String normalizeOtlpJson(String content) throws InvalidProtocolBufferException {
         try {
             JsonNode root = OBJECT_MAPPER.readTree(content);
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventReader.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventReader.java
new file mode 100644
index 0000000000..43e9e6febd
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventReader.java
@@ -0,0 +1,410 @@
+/*
+ * 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.hertzbeat.observability.ingestion.audit;
+
+import io.grpc.Status;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.redaction.OtlpIngestionRedactionService;
+import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/**
+ * Reads durable OTLP ingest RED audit events back from Greptime SQL.
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true")
+public class GreptimeOtlpIngestionAuditEventReader implements OtlpIngestionAuditEventReader {
+
+    private static final int MAX_LIMIT = 256;
+    private static final String STATUS_OK = "OK";
+    private static final Set<String> SUPPORTED_SIGNALS = Set.of("metrics", "logs", "traces");
+    private static final Set<String> SUPPORTED_PROTOCOLS = Set.of("http", "grpc");
+    private static final Set<String> SUPPORTED_OUTCOMES = Set.of("accepted", "rejected", "dropped");
+    private static final Set<String> SUPPORTED_STATUS_CODES = Set.of(
+            "OK",
+            "CANCELLED",
+            "UNKNOWN",
+            "INVALID_ARGUMENT",
+            "DEADLINE_EXCEEDED",
+            "NOT_FOUND",
+            "ALREADY_EXISTS",
+            "PERMISSION_DENIED",
+            "RESOURCE_EXHAUSTED",
+            "FAILED_PRECONDITION",
+            "ABORTED",
+            "OUT_OF_RANGE",
+            "UNIMPLEMENTED",
+            "INTERNAL",
+            "UNAVAILABLE",
+            "DATA_LOSS",
+            "UNAUTHENTICATED"
+    );
+    private static final BigInteger LONG_MAX_VALUE = BigInteger.valueOf(Long.MAX_VALUE);
+    private static final BigInteger LONG_MIN_VALUE = BigInteger.valueOf(Long.MIN_VALUE);
+    private static final BigDecimal LONG_MAX_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);
+    private static final BigDecimal LONG_MIN_DECIMAL = BigDecimal.valueOf(Long.MIN_VALUE);
+
+    private final ObjectProvider<GreptimeSqlQueryExecutor> greptimeSqlQueryExecutorProvider;
+    private final OtlpIngestionRedactionService redactionService = new OtlpIngestionRedactionService();
+
+    public GreptimeOtlpIngestionAuditEventReader(
+            ObjectProvider<GreptimeSqlQueryExecutor> greptimeSqlQueryExecutorProvider) {
+        this.greptimeSqlQueryExecutorProvider = greptimeSqlQueryExecutorProvider;
+    }
+
+    @Override
+    public List<OtlpIngestionAuditEvent> recentEvents(String workspaceId, int limit) {
+        return recentEvents(workspaceId, limit, null);
+    }
+
+    @Override
+    public List<OtlpIngestionAuditEvent> recentEvents(String workspaceId, int limit, Long startMillis) {
+        return recentEvents(workspaceId, limit, startMillis, null);
+    }
+
+    @Override
+    public List<OtlpIngestionAuditEvent> recentEvents(String workspaceId, int limit, Long startMillis,
+                                                      Long endMillis) {
+        GreptimeSqlQueryExecutor executor = greptimeSqlQueryExecutorOrNull();
+        if (executor == null) {
+            return Collections.emptyList();
+        }
+        try {
+            String safeWorkspaceId = StringUtils.trimToNull(workspaceId);
+            Long safeStartMillis = startMillis == null || startMillis <= 0 ? null : startMillis;
+            Long safeEndMillis = endMillis == null || endMillis <= 0 ? null : endMillis;
+            return executor.execute(recentEventsSql(workspaceId, limit, safeStartMillis, safeEndMillis)).stream()
+                    .filter(Objects::nonNull)
+                    .map(this::toEventOrNull)
+                    .filter(Objects::nonNull)
+                    .filter(this::hasSupportedAuditClassification)
+                    .filter(event -> event.observedAt() > 0)
+                    .filter(event -> safeWorkspaceId == null || safeWorkspaceId.equals(event.workspaceId()))
+                    .filter(event -> safeStartMillis == null || event.observedAt() >= safeStartMillis)
+                    .filter(event -> safeEndMillis == null || event.observedAt() <= safeEndMillis)
+                    .toList();
+        } catch (RuntimeException ex) {
+            log.warn("[otlp-ingest-red] failed to read Greptime audit events: {}", ex.getMessage(), ex);
+            return Collections.emptyList();
+        }
+    }
+
+    private GreptimeSqlQueryExecutor greptimeSqlQueryExecutorOrNull() {
+        try {
+            return greptimeSqlQueryExecutorProvider.getIfAvailable();
+        } catch (RuntimeException ex) {
+            log.warn("[otlp-ingest-red] failed to resolve Greptime audit query executor: {}", ex.getMessage(), ex);
+            return null;
+        }
+    }
+
+    private String recentEventsSql(String workspaceId, int limit, Long startMillis, Long endMillis) {
+        StringBuilder sql = new StringBuilder("SELECT observed_at, workspace_id, entity_id, ingest_id, signal, ")
+                .append("protocol, outcome, status_code, request_bytes, signal_items, duration_millis, reason ")
+                .append("FROM ")
+                .append(GreptimeOtlpIngestionAuditEventSink.TABLE_NAME);
+        List<String> filters = new ArrayList<>();
+        String safeWorkspaceId = StringUtils.trimToNull(workspaceId);
+        if (safeWorkspaceId != null) {
+            filters.add("workspace_id = '" + escapeSql(safeWorkspaceId) + "'");
+        }
+        if (startMillis != null && startMillis > 0) {
+            filters.add("observed_at >= to_timestamp_millis(" + startMillis + ")");
+        }
+        if (endMillis != null && endMillis > 0) {
+            filters.add("observed_at <= to_timestamp_millis(" + endMillis + ")");
+        }
+        if (!filters.isEmpty()) {
+            sql.append(" WHERE ")
+                    .append(String.join(" AND ", filters));
+        }
+        sql.append(" ORDER BY observed_at DESC LIMIT ")
+                .append(boundedLimit(limit));
+        return sql.toString();
+    }
+
+    private OtlpIngestionAuditEvent toEvent(Map<String, Object> row) {
+        return new OtlpIngestionAuditEvent(
+                normalizedDimension(row.get("signal")),
+                normalizedDimension(row.get("protocol")),
+                normalizedDimension(row.get("outcome")),
+                normalizedStatusCode(row.get("status_code")),
+                stringValue(row.get("workspace_id")),
+                stringValue(row.get("entity_id")),
+                stringValue(row.get("ingest_id")),
+                longValue(row.get("request_bytes"), 0L),
+                nullableLongValue(row.get("signal_items")),
+                redactedReason(row.get("reason")),
+                nullableLongValue(row.get("duration_millis")),
+                longValue(row.get("observed_at"), 0L)
+        );
+    }
+
+    private OtlpIngestionAuditEvent toEventOrNull(Map<String, Object> row) {
+        try {
+            return toEvent(row);
+        } catch (RuntimeException ex) {
+            log.warn("[otlp-ingest-red] skipped malformed Greptime audit row: {}", ex.getMessage());
+            return null;
+        }
+    }
+
+    private boolean hasSupportedAuditClassification(OtlpIngestionAuditEvent event) {
+        String signal = event.signal();
+        String protocol = event.protocol();
+        String outcome = event.outcome();
+        String statusCode = StringUtils.trimToNull(event.statusCode());
+        return signal != null && protocol != null
+                && SUPPORTED_SIGNALS.contains(signal)
+                && SUPPORTED_PROTOCOLS.contains(protocol)
+                && statusCode != null
+                && (isSupportedOutcome(outcome) || isNonOkStatus(statusCode));
+    }
+
+    private boolean isSupportedOutcome(String outcome) {
+        return outcome != null && SUPPORTED_OUTCOMES.contains(outcome.toLowerCase(Locale.ROOT));
+    }
+
+    private boolean isNonOkStatus(String statusCode) {
+        return !StringUtils.equalsIgnoreCase(STATUS_OK, statusCode);
+    }
+
+    private int boundedLimit(int limit) {
+        if (limit <= 0) {
+            return MAX_LIMIT;
+        }
+        return Math.min(limit, MAX_LIMIT);
+    }
+
+    private String stringValue(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value);
+        return StringUtils.trimToNull(text);
+    }
+
+    private String normalizedDimension(Object value) {
+        String text = stringValue(value);
+        return text == null ? null : text.toLowerCase(Locale.ROOT);
+    }
+
+    private String normalizedStatusCode(Object value) {
+        String text = stringValue(value);
+        if (text == null) {
+            return null;
+        }
+        String grpcStatusName = numericGrpcStatusName(text);
+        if (grpcStatusName != null) {
+            return grpcStatusName;
+        }
+        if (isNumericStatusCode(text)) {
+            return null;
+        }
+        String normalized = text.replaceAll("[\\s-]+", "_").replaceAll("^_+|_+$", "");
+        normalized = StringUtils.trimToNull(normalized) == null ? null : normalized.toUpperCase(Locale.ROOT);
+        return normalized != null && SUPPORTED_STATUS_CODES.contains(normalized) ? normalized : null;
+    }
+
+    private String numericGrpcStatusName(String text) {
+        String trimmed = StringUtils.trimToEmpty(text);
+        if (trimmed.isEmpty()) {
+            return null;
+        }
+        try {
+            BigDecimal numericStatus = new BigDecimal(trimmed).stripTrailingZeros();
+            if (numericStatus.scale() > 0) {
+                return null;
+            }
+            int codeValue = numericStatus.intValueExact();
+            if (codeValue < Status.Code.OK.value() || codeValue > Status.Code.UNAUTHENTICATED.value()) {
+                return null;
+            }
+            return Status.fromCodeValue(codeValue).getCode().name();
+        } catch (ArithmeticException | NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    private boolean isNumericStatusCode(String text) {
+        try {
+            new BigDecimal(text);
+            return true;
+        } catch (NumberFormatException ignored) {
+            return false;
+        }
+    }
+
+    private String redactedReason(Object value) {
+        return StringUtils.trimToNull(redactionService.redactText(stringValue(value)));
+    }
+
+    private Long nullableLongValue(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof BigDecimal bigDecimal) {
+            return nonNegativeLong(clampLong(bigDecimal));
+        }
+        if (value instanceof BigInteger bigInteger) {
+            return nonNegativeLong(clampLong(bigInteger));
+        }
+        if (value instanceof Number number) {
+            return nonNegativeLong(number.longValue());
+        }
+        String text = StringUtils.trimToNull(String.valueOf(value));
+        if (text == null) {
+            return null;
+        }
+        return integerTextLongValue(text);
+    }
+
+    private long longValue(Object value, long defaultValue) {
+        if (value == null) {
+            return defaultValue;
+        }
+        if (value instanceof BigDecimal bigDecimal) {
+            return nonNegativeLong(clampLong(bigDecimal));
+        }
+        if (value instanceof BigInteger bigInteger) {
+            return nonNegativeLong(clampLong(bigInteger));
+        }
+        if (value instanceof Number number) {
+            return nonNegativeLong(number.longValue());
+        }
+        if (value instanceof Timestamp timestamp) {
+            return nonNegativeLong(timestamp.toInstant().toEpochMilli());
+        }
+        if (value instanceof Date date) {
+            return nonNegativeLong(date.getTime());
+        }
+        if (value instanceof Instant instant) {
+            return nonNegativeLong(instant.toEpochMilli());
+        }
+        if (value instanceof OffsetDateTime offsetDateTime) {
+            return nonNegativeLong(offsetDateTime.toInstant().toEpochMilli());
+        }
+        if (value instanceof ZonedDateTime zonedDateTime) {
+            return nonNegativeLong(zonedDateTime.toInstant().toEpochMilli());
+        }
+        if (value instanceof LocalDateTime localDateTime) {
+            return nonNegativeLong(localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        }
+        String text = StringUtils.trimToNull(String.valueOf(value));
+        if (text == null) {
+            return defaultValue;
+        }
+        Long integerValue = integerTextLongValue(text);
+        if (integerValue != null) {
+            return integerValue;
+        }
+        return nonNegativeLong(parseTimestamp(text, defaultValue));
+    }
+
+    private long nonNegativeLong(long value) {
+        return Math.max(value, 0L);
+    }
+
+    private Long integerTextLongValue(String text) {
+        if (!text.matches("[+-]?\\d+")) {
+            return null;
+        }
+        try {
+            return nonNegativeLong(clampLong(new BigInteger(text)));
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    private long clampLong(BigInteger value) {
+        if (value.compareTo(LONG_MAX_VALUE) > 0) {
+            return Long.MAX_VALUE;
+        }
+        if (value.compareTo(LONG_MIN_VALUE) < 0) {
+            return Long.MIN_VALUE;
+        }
+        return value.longValue();
+    }
+
+    private long clampLong(BigDecimal value) {
+        if (value.compareTo(LONG_MAX_DECIMAL) > 0) {
+            return Long.MAX_VALUE;
+        }
+        if (value.compareTo(LONG_MIN_DECIMAL) < 0) {
+            return Long.MIN_VALUE;
+        }
+        return value.longValue();
+    }
+
+    private long parseTimestamp(String text, long defaultValue) {
+        try {
+            return Instant.parse(text).toEpochMilli();
+        } catch (DateTimeParseException ignored) {
+            // Try Greptime/DataFusion timestamp text variants.
+        }
+        String normalizedText = normalizedTimestampText(text);
+        try {
+            return OffsetDateTime.parse(normalizedText, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
+                    .toInstant()
+                    .toEpochMilli();
+        } catch (DateTimeParseException ignored) {
+            // Try Greptime/DataFusion's common timestamp text without a zone.
+        }
+        try {
+            return LocalDateTime.parse(normalizedText, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
+                    .atZone(ZoneId.systemDefault())
+                    .toInstant()
+                    .toEpochMilli();
+        } catch (DateTimeParseException ignored) {
+            return defaultValue;
+        }
+    }
+
+    private String normalizedTimestampText(String text) {
+        return text.replaceFirst("\\s+", "T")
+                .replaceFirst("(?i)\\s+UTC$", "Z")
+                .replaceFirst("\\s+(?=Z$|[+-]\\d{2}:?\\d{2}$)", "")
+                .replaceFirst("([+-]\\d{2})(\\d{2})$", "$1:$2");
+    }
+
+    private String escapeSql(String value) {
+        return value == null ? "" : value.replace("'", "''");
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventSink.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventSink.java
new file mode 100644
index 0000000000..b45c9e4b7b
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventSink.java
@@ -0,0 +1,300 @@
+/*
+ * 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.hertzbeat.observability.ingestion.audit;
+
+import io.grpc.Status;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.redaction.OtlpIngestionRedactionService;
+import org.apache.hertzbeat.observability.ingestion.retry.OtlpIngestionRetryService;
+import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * Persists OTLP ingest RED audit events into Greptime SQL for durable release-readiness evidence.
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true")
+public class GreptimeOtlpIngestionAuditEventSink implements OtlpIngestionAuditEventSink {
+
+    static final String TABLE_NAME = "hertzbeat_otlp_ingest_red";
+
+    private static final String SQL_PATH = "/v1/sql";
+    private static final String DEFAULT_GREPTIME_DB_NAME = "public";
+    private static final Set<String> SUPPORTED_SIGNALS = Set.of("metrics", "logs", "traces");
+    private static final Set<String> SUPPORTED_PROTOCOLS = Set.of("http", "grpc");
+    private static final Set<String> SUPPORTED_OUTCOMES = Set.of("accepted", "rejected", "dropped");
+    private static final Set<String> SUPPORTED_STATUS_CODES = Set.of(
+            "OK",
+            "CANCELLED",
+            "UNKNOWN",
+            "INVALID_ARGUMENT",
+            "DEADLINE_EXCEEDED",
+            "NOT_FOUND",
+            "ALREADY_EXISTS",
+            "PERMISSION_DENIED",
+            "RESOURCE_EXHAUSTED",
+            "FAILED_PRECONDITION",
+            "ABORTED",
+            "OUT_OF_RANGE",
+            "UNIMPLEMENTED",
+            "INTERNAL",
+            "UNAVAILABLE",
+            "DATA_LOSS",
+            "UNAUTHENTICATED"
+    );
+    private static final String CREATE_TABLE_SQL = """
+            CREATE TABLE IF NOT EXISTS hertzbeat_otlp_ingest_red (
+              observed_at TIMESTAMP(3) TIME INDEX,
+              workspace_id STRING NULL,
+              entity_id STRING NULL,
+              ingest_id STRING NULL,
+              signal STRING,
+              protocol STRING,
+              outcome STRING,
+              status_code STRING,
+              request_bytes BIGINT,
+              signal_items BIGINT NULL,
+              duration_millis BIGINT NULL,
+              reason STRING NULL,
+              PRIMARY KEY(workspace_id, signal, protocol, outcome, status_code)
+            )
+            """;
+
+    private final RestTemplate restTemplate;
+    private final ObjectProvider<GreptimeProperties> greptimePropertiesProvider;
+    private final OtlpIngestionRetryService retryService;
+    private final OtlpIngestionRedactionService redactionService;
+    private final AtomicBoolean tableInitialized = new AtomicBoolean(false);
+
+    GreptimeOtlpIngestionAuditEventSink(RestTemplate restTemplate,
+                                        ObjectProvider<GreptimeProperties> greptimePropertiesProvider) {
+        this(restTemplate, greptimePropertiesProvider, new OtlpIngestionRetryService(),
+                new OtlpIngestionRedactionService());
+    }
+
+    public GreptimeOtlpIngestionAuditEventSink(RestTemplate restTemplate,
+                                               ObjectProvider<GreptimeProperties> greptimePropertiesProvider,
+                                               OtlpIngestionRetryService retryService) {
+        this(restTemplate, greptimePropertiesProvider, retryService, new OtlpIngestionRedactionService());
+    }
+
+    @Autowired
+    public GreptimeOtlpIngestionAuditEventSink(RestTemplate restTemplate,
+                                               ObjectProvider<GreptimeProperties> greptimePropertiesProvider,
+                                               OtlpIngestionRetryService retryService,
+                                               OtlpIngestionRedactionService redactionService) {
+        this.restTemplate = restTemplate;
+        this.greptimePropertiesProvider = greptimePropertiesProvider;
+        this.retryService = retryService == null ? new OtlpIngestionRetryService() : retryService;
+        this.redactionService = redactionService == null ? new OtlpIngestionRedactionService() : redactionService;
+    }
+
+    @Override
+    public void record(OtlpIngestionAuditEvent event) {
+        GreptimeProperties greptimeProperties = greptimePropertiesOrNull();
+        if (event == null || greptimeProperties == null || !greptimeProperties.enabled()
+                || StringUtils.isBlank(greptimeProperties.httpEndpoint()) || event.observedAt() <= 0
+                || !hasSupportedAuditClassification(event)) {
+            return;
+        }
+        ensureTable(greptimeProperties);
+        execute(greptimeProperties, insertSql(event));
+    }
+
+    private GreptimeProperties greptimePropertiesOrNull() {
+        try {
+            return greptimePropertiesProvider.getIfAvailable();
+        } catch (RuntimeException ex) {
+            log.warn("[otlp-ingest-red] failed to resolve Greptime properties: {}", ex.getMessage(), ex);
+            return null;
+        }
+    }
+
+    private void ensureTable(GreptimeProperties greptimeProperties) {
+        if (tableInitialized.get()) {
+            return;
+        }
+        synchronized (tableInitialized) {
+            if (tableInitialized.get()) {
+                return;
+            }
+            execute(greptimeProperties, CREATE_TABLE_SQL.strip());
+            tableInitialized.set(true);
+        }
+    }
+
+    private void execute(GreptimeProperties greptimeProperties, String sql) {
+        try {
+            ResponseEntity<String> response = retryService.execute(() -> restTemplate.exchange(
+                            endpoint(greptimeProperties),
+                            HttpMethod.POST,
+                            sqlRequest(greptimeProperties, sql),
+                            String.class
+                    ),
+                    responseEntity -> responseEntity == null
+                            || retryService.isRetryableStatus(responseEntity.getStatusCode()));
+            if (response == null) {
+                throw new IllegalStateException("Greptime SQL returned no response");
+            }
+            HttpStatusCode statusCode = response.getStatusCode();
+            if (!statusCode.is2xxSuccessful()) {
+                throw new IllegalStateException("Greptime SQL returned " + statusCode);
+            }
+        } catch (RestClientException ex) {
+            log.warn("[otlp-ingest-red] failed to persist Greptime audit event: {}", ex.getMessage(), ex);
+            throw ex;
+        }
+    }
+
+    private HttpEntity<String> sqlRequest(GreptimeProperties greptimeProperties, String sql) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.setAccept(List.of(MediaType.APPLICATION_JSON));
+        String username = StringUtils.trimToNull(greptimeProperties.username());
+        String password = StringUtils.trimToNull(greptimeProperties.password());
+        if (username != null && password != null) {
+            headers.setBasicAuth(username, password, StandardCharsets.UTF_8);
+        }
+        return new HttpEntity<>("sql=" + UriUtils.encodeQueryParam(sql, StandardCharsets.UTF_8), headers);
+    }
+
+    private String endpoint(GreptimeProperties greptimeProperties) {
+        String endpoint = StringUtils.stripEnd(StringUtils.trim(greptimeProperties.httpEndpoint()), "/") + SQL_PATH;
+        return endpoint + "?db=" + UriUtils.encodeQueryParam(database(greptimeProperties.database()),
+                StandardCharsets.UTF_8);
+    }
+
+    private String database(String configuredDatabase) {
+        return StringUtils.defaultIfBlank(StringUtils.trim(configuredDatabase), DEFAULT_GREPTIME_DB_NAME);
+    }
+
+    private String insertSql(OtlpIngestionAuditEvent event) {
+        return "INSERT INTO " + TABLE_NAME + " (observed_at, workspace_id, entity_id, ingest_id, signal, protocol, "
+                + "outcome, status_code, request_bytes, signal_items, duration_millis, reason) VALUES ("
+                + "to_timestamp_millis(" + Math.max(event.observedAt(), 0L) + "), "
+                + sqlLiteral(trimmedOptional(event.workspaceId())) + ", "
+                + sqlLiteral(trimmedOptional(event.entityId())) + ", "
+                + sqlLiteral(trimmedOptional(event.ingestId())) + ", "
+                + sqlLiteral(normalizedDimension(event.signal())) + ", "
+                + sqlLiteral(normalizedDimension(event.protocol())) + ", "
+                + sqlLiteral(normalizedDimension(event.outcome())) + ", "
+                + sqlLiteral(normalizedStatusCode(event.statusCode())) + ", "
+                + Math.max(event.requestBytes(), 0L) + ", "
+                + sqlNumber(event.signalItems()) + ", "
+                + sqlNumber(event.durationMillis()) + ", "
+                + sqlLiteral(redactedReason(event.reason())) + ")";
+    }
+
+    private boolean hasSupportedAuditClassification(OtlpIngestionAuditEvent event) {
+        String signal = normalizedDimension(event.signal());
+        String protocol = normalizedDimension(event.protocol());
+        String outcome = normalizedDimension(event.outcome());
+        String statusCode = normalizedStatusCode(event.statusCode());
+        return signal != null && protocol != null && outcome != null
+                && SUPPORTED_SIGNALS.contains(signal)
+                && SUPPORTED_PROTOCOLS.contains(protocol)
+                && SUPPORTED_OUTCOMES.contains(outcome)
+                && statusCode != null;
+    }
+
+    private String normalizedDimension(String value) {
+        String text = StringUtils.trimToNull(value);
+        return text == null ? null : text.toLowerCase(Locale.ROOT);
+    }
+
+    private String normalizedStatusCode(String value) {
+        String text = StringUtils.trimToNull(value);
+        if (text == null) {
+            return null;
+        }
+        String grpcStatusName = numericGrpcStatusName(text);
+        if (grpcStatusName != null) {
+            return grpcStatusName;
+        }
+        if (isNumericStatusCode(text)) {
+            return null;
+        }
+        String normalized = text.replaceAll("[\\s-]+", "_").replaceAll("^_+|_+$", "");
+        normalized = StringUtils.trimToNull(normalized) == null ? null : normalized.toUpperCase(Locale.ROOT);
+        return normalized != null && SUPPORTED_STATUS_CODES.contains(normalized) ? normalized : null;
+    }
+
+    private String numericGrpcStatusName(String text) {
+        try {
+            BigDecimal numericStatus = new BigDecimal(text).stripTrailingZeros();
+            if (numericStatus.scale() > 0) {
+                return null;
+            }
+            int codeValue = numericStatus.intValueExact();
+            if (codeValue < Status.Code.OK.value() || codeValue > Status.Code.UNAUTHENTICATED.value()) {
+                return null;
+            }
+            return Status.fromCodeValue(codeValue).getCode().name();
+        } catch (ArithmeticException | NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    private boolean isNumericStatusCode(String text) {
+        try {
+            new BigDecimal(text);
+            return true;
+        } catch (NumberFormatException ignored) {
+            return false;
+        }
+    }
+
+    private String trimmedOptional(String value) {
+        return StringUtils.trimToNull(value);
+    }
+
+    private String redactedReason(String value) {
+        return StringUtils.trimToNull(redactionService.redactText(value));
+    }
+
+    private String sqlNumber(Long value) {
+        return value == null ? "NULL" : String.valueOf(Math.max(value, 0L));
+    }
+
+    private String sqlLiteral(String value) {
+        if (value == null) {
+            return "NULL";
+        }
+        return "'" + value.replace("'", "''") + "'";
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEvent.java
similarity index 55%
copy from hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
copy to hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEvent.java
index d4398dd193..f5ba3cb078 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEvent.java
@@ -15,24 +15,23 @@
  * limitations under the License.
  */
 
-package org.apache.hertzbeat.observability.ingestion.enricher;
-
-import java.util.UUID;
-import org.apache.commons.lang3.StringUtils;
+package org.apache.hertzbeat.observability.ingestion.audit;
 
 /**
- * Request-scoped HertzBeat correlation values to inject into OTLP records.
+ * Recent OTLP ingest audit/self-observability event.
  */
-public record OtlpCorrelationContext(String ingestId, String entityId, String workspaceId) {
-
-    public static OtlpCorrelationContext empty() {
-        return new OtlpCorrelationContext(null, null, null);
-    }
-
-    OtlpCorrelationContext withIngestId() {
-        if (StringUtils.isNotBlank(ingestId)) {
-            return this;
-        }
-        return new OtlpCorrelationContext(UUID.randomUUID().toString(), entityId, workspaceId);
-    }
+public record OtlpIngestionAuditEvent(
+        String signal,
+        String protocol,
+        String outcome,
+        String statusCode,
+        String workspaceId,
+        String entityId,
+        String ingestId,
+        long requestBytes,
+        Long signalItems,
+        String reason,
+        Long durationMillis,
+        long observedAt
+) {
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEventReader.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEventReader.java
new file mode 100644
index 0000000000..45a70836cb
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEventReader.java
@@ -0,0 +1,62 @@
+/*
+ * 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.hertzbeat.observability.ingestion.audit;
+
+import java.util.Comparator;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Durable/readback source for OTLP ingest audit events.
+ */
+@FunctionalInterface
+public interface OtlpIngestionAuditEventReader {
+
+    int DEFAULT_MAX_LIMIT = 256;
+
+    List<OtlpIngestionAuditEvent> recentEvents(String workspaceId, int limit);
+
+    default List<OtlpIngestionAuditEvent> recentEvents(String workspaceId, int limit, Long startMillis) {
+        return recentEvents(workspaceId, limit, startMillis, null);
+    }
+
+    default List<OtlpIngestionAuditEvent> recentEvents(String workspaceId, int limit, Long startMillis,
+                                                       Long endMillis) {
+        Long safeStartMillis = startMillis == null || startMillis <= 0 ? null : startMillis;
+        Long safeEndMillis = endMillis == null || endMillis <= 0 ? null : endMillis;
+        String safeWorkspaceId = StringUtils.trimToNull(workspaceId);
+        int safeLimit = boundedLimit(limit);
+        return recentEvents(safeWorkspaceId, safeLimit).stream()
+                .filter(event -> event != null)
+                .filter(event -> event.observedAt() > 0)
+                .filter(event -> safeWorkspaceId == null
+                        || safeWorkspaceId.equals(StringUtils.trimToNull(event.workspaceId())))
+                .filter(event -> safeStartMillis == null || event.observedAt() >= safeStartMillis)
+                .filter(event -> safeEndMillis == null || event.observedAt() <= safeEndMillis)
+                .sorted(Comparator.comparingLong(OtlpIngestionAuditEvent::observedAt).reversed())
+                .limit(safeLimit)
+                .toList();
+    }
+
+    private static int boundedLimit(int limit) {
+        if (limit <= 0) {
+            return DEFAULT_MAX_LIMIT;
+        }
+        return Math.min(limit, DEFAULT_MAX_LIMIT);
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEventSink.java
similarity index 55%
copy from hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
copy to hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEventSink.java
index d4398dd193..636e4c81d3 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditEventSink.java
@@ -15,24 +15,13 @@
  * limitations under the License.
  */
 
-package org.apache.hertzbeat.observability.ingestion.enricher;
-
-import java.util.UUID;
-import org.apache.commons.lang3.StringUtils;
+package org.apache.hertzbeat.observability.ingestion.audit;
 
 /**
- * Request-scoped HertzBeat correlation values to inject into OTLP records.
+ * Durable/exportable sink for OTLP ingest audit events.
  */
-public record OtlpCorrelationContext(String ingestId, String entityId, String workspaceId) {
-
-    public static OtlpCorrelationContext empty() {
-        return new OtlpCorrelationContext(null, null, null);
-    }
+@FunctionalInterface
+public interface OtlpIngestionAuditEventSink {
 
-    OtlpCorrelationContext withIngestId() {
-        if (StringUtils.isNotBlank(ingestId)) {
-            return this;
-        }
-        return new OtlpCorrelationContext(UUID.randomUUID().toString(), entityId, workspaceId);
-    }
+    void record(OtlpIngestionAuditEvent event);
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditService.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditService.java
new file mode 100644
index 0000000000..95c2f651ca
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/audit/OtlpIngestionAuditService.java
@@ -0,0 +1,188 @@
+/*
+ * 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.hertzbeat.observability.ingestion.audit;
+
+import io.grpc.Status;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.enricher.OtlpCorrelationContext;
+import org.apache.hertzbeat.observability.ingestion.redaction.OtlpIngestionRedactionService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * Shared OTLP ingest audit and self-observability boundary.
+ */
+@Service
+public class OtlpIngestionAuditService {
+
+    private static final int MAX_RECENT_EVENTS = 256;
+    private static final String OUTCOME_ACCEPTED = "accepted";
+    private static final String OUTCOME_REJECTED = "rejected";
+    private static final String OUTCOME_DROPPED = "dropped";
+    private static final String STATUS_OK = "OK";
+    private static final String UNKNOWN = "unknown";
+    private static final Set<String> SUPPORTED_SIGNALS = Set.of("metrics", "logs", "traces");
+    private static final Set<String> SUPPORTED_PROTOCOLS = Set.of("http", "grpc");
+
+    private final ConcurrentLinkedDeque<OtlpIngestionAuditEvent> recentEvents = new ConcurrentLinkedDeque<>();
+    private final OtlpIngestionRedactionService redactionService;
+    private final List<OtlpIngestionAuditEventSink> eventSinks;
+
+    public OtlpIngestionAuditService() {
+        this(new OtlpIngestionRedactionService());
+    }
+
+    @Autowired
+    public OtlpIngestionAuditService(OtlpIngestionRedactionService redactionService,
+                                     List<OtlpIngestionAuditEventSink> eventSinks) {
+        this.redactionService = redactionService == null ? new OtlpIngestionRedactionService() : redactionService;
+        this.eventSinks = eventSinks == null ? List.of() : List.copyOf(eventSinks.stream()
+                .filter(Objects::nonNull)
+                .toList());
+    }
+
+    public OtlpIngestionAuditService(OtlpIngestionRedactionService redactionService) {
+        this(redactionService, List.of());
+    }
+
+    public void recordAccepted(String signal, String protocol, OtlpCorrelationContext context, long requestBytes) {
+        recordAccepted(signal, protocol, context, requestBytes, null, null);
+    }
+
+    public void recordAccepted(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                               Long durationMillis) {
+        recordAccepted(signal, protocol, context, requestBytes, null, durationMillis);
+    }
+
+    public void recordAccepted(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                               Long signalItems, Long durationMillis) {
+        record(signal, protocol, OUTCOME_ACCEPTED, STATUS_OK, context, requestBytes, signalItems, null,
+                durationMillis);
+    }
+
+    public void recordDropped(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                              Long signalItems, String reason, Long durationMillis) {
+        record(signal, protocol, OUTCOME_DROPPED, STATUS_OK, context, requestBytes, signalItems, reason,
+                durationMillis);
+    }
+
+    public void recordRejected(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                               Status.Code statusCode, String reason) {
+        recordRejected(signal, protocol, context, requestBytes, statusCode, reason, null, null);
+    }
+
+    public void recordRejected(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                               Status.Code statusCode, String reason, Long durationMillis) {
+        recordRejected(signal, protocol, context, requestBytes, statusCode, reason, null, durationMillis);
+    }
+
+    public void recordRejected(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                               Status.Code statusCode, String reason, Long signalItems, Long durationMillis) {
+        record(signal, protocol, OUTCOME_REJECTED, statusCode == null ? Status.Code.UNKNOWN.name() : statusCode.name(),
+                context, requestBytes, signalItems, reason, durationMillis);
+    }
+
+    public void recordRejected(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                               Throwable throwable) {
+        recordRejected(signal, protocol, context, requestBytes, throwable, null, null);
+    }
+
+    public void recordRejected(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                               Throwable throwable, Long durationMillis) {
+        recordRejected(signal, protocol, context, requestBytes, throwable, null, durationMillis);
+    }
+
+    public void recordRejected(String signal, String protocol, OtlpCorrelationContext context, long requestBytes,
+                               Throwable throwable, Long signalItems, Long durationMillis) {
+        if (throwable == null) {
+            recordRejected(signal, protocol, context, requestBytes, Status.Code.UNKNOWN, null, signalItems,
+                    durationMillis);
+            return;
+        }
+        Status status = Status.fromThrowable(throwable);
+        recordRejected(signal, protocol, context, requestBytes, status.getCode(),
+                StringUtils.defaultIfBlank(status.getDescription(),
+                        throwable.getMessage()), signalItems, durationMillis);
+    }
+
+    public List<OtlpIngestionAuditEvent> recentEvents() {
+        return Collections.unmodifiableList(recentEvents.stream().toList());
+    }
+
+    private void record(String signal, String protocol, String outcome, String statusCode,
+                        OtlpCorrelationContext context, long requestBytes, Long signalItems, String reason,
+                        Long durationMillis) {
+        String normalizedSignal = normalizeLowerDimension(signal);
+        String normalizedProtocol = normalizeLowerDimension(protocol);
+        if (!hasSupportedSignalProtocol(normalizedSignal, normalizedProtocol)) {
+            return;
+        }
+        OtlpCorrelationContext safeContext = context == null ? OtlpCorrelationContext.empty() : context;
+        OtlpIngestionAuditEvent event = new OtlpIngestionAuditEvent(
+                normalizedSignal,
+                normalizedProtocol,
+                normalizeLowerDimension(outcome),
+                normalizeDimension(statusCode),
+                StringUtils.trimToNull(safeContext.workspaceId()),
+                StringUtils.trimToNull(safeContext.entityId()),
+                StringUtils.trimToNull(safeContext.ingestId()),
+                Math.max(requestBytes, 0L),
+                signalItems == null ? null : Math.max(signalItems, 0L),
+                StringUtils.trimToNull(redactionService.redactText(reason)),
+                durationMillis == null ? null : Math.max(durationMillis, 0L),
+                System.currentTimeMillis()
+        );
+        recentEvents.addFirst(event);
+        trimRecentEvents();
+        publishEvent(event);
+    }
+
+    private void trimRecentEvents() {
+        while (recentEvents.size() > MAX_RECENT_EVENTS) {
+            recentEvents.pollLast();
+        }
+    }
+
+    private void publishEvent(OtlpIngestionAuditEvent event) {
+        for (OtlpIngestionAuditEventSink eventSink : eventSinks) {
+            try {
+                eventSink.record(event);
+            } catch (RuntimeException ignored) {
+                // Ingest audit export must not make the ingest path fail.
+            }
+        }
+    }
+
+    private boolean hasSupportedSignalProtocol(String signal, String protocol) {
+        return SUPPORTED_SIGNALS.contains(signal) && SUPPORTED_PROTOCOLS.contains(protocol);
+    }
+
+    private String normalizeDimension(String value) {
+        return StringUtils.defaultIfBlank(StringUtils.trimToNull(value), UNKNOWN);
+    }
+
+    private String normalizeLowerDimension(String value) {
+        return normalizeDimension(value).toLowerCase(Locale.ROOT);
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/config/OtlpGrpcServerConfig.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/config/OtlpGrpcServerConfig.java
index 0121ce1c32..be81d167da 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/config/OtlpGrpcServerConfig.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/config/OtlpGrpcServerConfig.java
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.observability.ingestion.config;
 
 import com.usthe.sureness.util.JsonWebTokenUtil;
 import io.grpc.Contexts;
+import io.grpc.ForwardingServerCallListener;
 import io.grpc.Metadata;
 import io.grpc.Server;
 import io.grpc.ServerCall;
@@ -35,6 +36,8 @@ import java.util.List;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.common.observability.gateway.ObservabilityAccessTokenGateway;
 import org.apache.hertzbeat.observability.ingestion.grpc.OtlpGrpcLogsService;
 import org.apache.hertzbeat.observability.ingestion.grpc.OtlpGrpcMetricsService;
@@ -103,12 +106,15 @@ public class OtlpGrpcServerConfig {
     static final class BearerTokenInterceptor implements ServerInterceptor {
         private static final Metadata.Key<String> AUTHORIZATION =
                 Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
+        private static final Metadata.Key<String> WORKSPACE_ID =
+                Metadata.Key.of(AuthTokenScopes.WORKSPACE_ID_HEADER.toLowerCase(), Metadata.ASCII_STRING_MARSHALLER);
         private final ObservabilityAccessTokenGateway accessTokenGateway;
 
         @Override
         public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call,
                                                                      Metadata headers,
                                                                      ServerCallHandler<ReqT, RespT> next) {
+            AuthTokenRequestContext.clear();
             String authorization = headers.get(AUTHORIZATION);
             if (!StringUtils.startsWithIgnoreCase(StringUtils.defaultString(authorization), "Bearer ")) {
                 call.close(Status.UNAUTHENTICATED.withDescription("Missing OTLP access token."), new Metadata());
@@ -116,6 +122,7 @@ public class OtlpGrpcServerConfig {
                 };
             }
             String token = StringUtils.substringAfter(authorization, "Bearer ").trim();
+            String workspaceId = null;
             try {
                 Claims claims = JsonWebTokenUtil.parseJwt(token);
                 Boolean refresh = claims.get("refresh", Boolean.class);
@@ -126,7 +133,10 @@ public class OtlpGrpcServerConfig {
                 if (!Boolean.TRUE.equals(managed)) {
                     throw new IllegalArgumentException("Managed token is required.");
                 }
-                String rejectReason = accessTokenGateway.checkTokenStatus(token);
+                workspaceId = resolveWorkspaceId(headers, claims);
+                String rejectReason = workspaceId == null
+                        ? accessTokenGateway.checkTokenStatus(token, AuthTokenScopes.OTLP_INGEST)
+                        : accessTokenGateway.checkTokenStatus(token, AuthTokenScopes.OTLP_INGEST, workspaceId);
                 if (rejectReason != null) {
                     call.close(Status.UNAUTHENTICATED.withDescription(rejectReason), new Metadata());
                     return new ServerCall.Listener<>() {
@@ -146,11 +156,82 @@ public class OtlpGrpcServerConfig {
                 }
                 accessTokenGateway.touchTokenLastUsedTime(token);
             } catch (Exception ex) {
+                AuthTokenRequestContext.clear();
                 call.close(Status.UNAUTHENTICATED.withDescription("Invalid OTLP access token."), new Metadata());
                 return new ServerCall.Listener<>() {
                 };
             }
-            return Contexts.interceptCall(io.grpc.Context.current(), call, headers, next);
+            return interceptWorkspaceBoundCall(workspaceId, call, headers, next);
+        }
+
+        private String resolveWorkspaceId(Metadata headers, Claims claims) {
+            String workspaceId = StringUtils.trimToNull(headers.get(WORKSPACE_ID));
+            if (workspaceId == null) {
+                workspaceId = StringUtils.trimToNull(claims.get(AuthTokenScopes.CLAIM_WORKSPACE_ID, String.class));
+            }
+            return workspaceId == null ? null : AuthTokenScopes.normalizeWorkspaceId(workspaceId);
+        }
+
+        private <ReqT, RespT> ServerCall.Listener<ReqT> interceptWorkspaceBoundCall(
+                String workspaceId,
+                ServerCall<ReqT, RespT> call,
+                Metadata headers,
+                ServerCallHandler<ReqT, RespT> next) {
+            bindWorkspace(workspaceId);
+            try {
+                ServerCall.Listener<ReqT> listener = Contexts.interceptCall(
+                        io.grpc.Context.current(), call, headers, next);
+                return workspaceBoundListener(listener, workspaceId);
+            } finally {
+                AuthTokenRequestContext.clear();
+            }
+        }
+
+        private <ReqT> ServerCall.Listener<ReqT> workspaceBoundListener(
+                ServerCall.Listener<ReqT> listener, String workspaceId) {
+            return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(listener) {
+                @Override
+                public void onMessage(ReqT message) {
+                    withWorkspace(workspaceId, () -> super.onMessage(message));
+                }
+
+                @Override
+                public void onHalfClose() {
+                    withWorkspace(workspaceId, super::onHalfClose);
+                }
+
+                @Override
+                public void onCancel() {
+                    withWorkspace(workspaceId, super::onCancel);
+                }
+
+                @Override
+                public void onComplete() {
+                    withWorkspace(workspaceId, super::onComplete);
+                }
+
+                @Override
+                public void onReady() {
+                    withWorkspace(workspaceId, super::onReady);
+                }
+            };
+        }
+
+        private void withWorkspace(String workspaceId, Runnable action) {
+            bindWorkspace(workspaceId);
+            try {
+                action.run();
+            } finally {
+                AuthTokenRequestContext.clear();
+            }
+        }
+
+        private void bindWorkspace(String workspaceId) {
+            if (StringUtils.isBlank(workspaceId)) {
+                AuthTokenRequestContext.clear();
+                return;
+            }
+            AuthTokenRequestContext.bindWorkspaceId(workspaceId);
         }
     }
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
index f8aa57f429..5f12e9c217 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
@@ -25,7 +25,9 @@ import org.apache.hertzbeat.common.entity.dto.Message;
 import org.apache.hertzbeat.common.observability.dto.binding.OtlpEntityBindingSummaryDto;
 import org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionGuideDto;
 import org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionOverviewDto;
+import org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionRedSummaryDto;
 import org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsConsoleDto;
+import org.apache.hertzbeat.observability.ingestion.red.OtlpIngestionRedSummaryService;
 import org.apache.hertzbeat.observability.ingestion.service.OtlpIngestionWorkspaceService;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -43,6 +45,7 @@ import org.springframework.web.bind.annotation.RequestParam;
 public class OtlpIngestionController {
 
     private final OtlpIngestionWorkspaceService otlpIngestionWorkspaceService;
+    private final OtlpIngestionRedSummaryService otlpIngestionRedSummaryService;
 
     @GetMapping("/overview")
     @Operation(summary = "Unified OTLP ingestion overview")
@@ -62,6 +65,14 @@ public class OtlpIngestionController {
         return ResponseEntity.ok(Message.success(otlpIngestionWorkspaceService.getBindingSummary()));
     }
 
+    @GetMapping("/intake/red")
+    @Operation(summary = "Recent OTLP ingest request, error, and duration summary")
+    public ResponseEntity<Message<OtlpIngestionRedSummaryDto>> intakeRedSummary(
+            @RequestParam(value = "start", required = false) Long start,
+            @RequestParam(value = "end", required = false) Long end) {
+        return ResponseEntity.ok(Message.success(otlpIngestionRedSummaryService.getSummary(start, end)));
+    }
+
     @GetMapping("/metrics/console")
     @Operation(summary = "OTLP metrics console query workspace")
     public ResponseEntity<Message<OtlpMetricsConsoleDto>> metricsConsole(
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpLogController.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpLogController.java
index d78d3deef8..baeeebe070 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpLogController.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpLogController.java
@@ -17,22 +17,11 @@
 
 package org.apache.hertzbeat.observability.ingestion.controller;
 
-import com.fasterxml.jackson.core.io.JsonStringEncoder;
-import com.google.protobuf.InvalidProtocolBufferException;
-import com.google.protobuf.util.JsonFormat;
-import com.google.rpc.Status;
-import io.grpc.StatusRuntimeException;
-import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
-import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceResponse;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.hertzbeat.observability.ingestion.adapter.OtlpLogProtocolAdapter;
-import org.apache.hertzbeat.observability.ingestion.enricher.OtlpCorrelationContext;
-import org.apache.hertzbeat.observability.ingestion.enricher.OtlpCorrelationEnricher;
-import org.apache.hertzbeat.observability.ingestion.forwarder.GreptimeOtlpForwarder;
+import lombok.RequiredArgsConstructor;
+import org.apache.hertzbeat.observability.ingestion.service.OtlpGrpcIngestionService;
 import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -51,60 +40,33 @@ import org.springframework.web.bind.annotation.RestController;
 @Tag(name = "OTLP Log Controller")
 @RestController
 @RequestMapping(path = {"/api/logs/otlp", "/api/otlp"})
-@Slf4j
+@RequiredArgsConstructor
 public class OtlpLogController {
 
     private static final String CONTENT_TYPE_PROTOBUF = "application/x-protobuf";
-    private static final ExportLogsServiceResponse EMPTY_RESPONSE = ExportLogsServiceResponse.newBuilder().build();
+    private static final String CONTENT_TYPE_PROTOBUF_ALT = "application/protobuf";
 
-    private final OtlpLogProtocolAdapter otlpLogProtocolAdapter;
-    private final OtlpCorrelationEnricher otlpCorrelationEnricher;
-    private final GreptimeOtlpForwarder greptimeOtlpForwarder;
-
-    public OtlpLogController(OtlpLogProtocolAdapter otlpLogProtocolAdapter,
-                             OtlpCorrelationEnricher otlpCorrelationEnricher,
-                             GreptimeOtlpForwarder greptimeOtlpForwarder) {
-        this.otlpLogProtocolAdapter = otlpLogProtocolAdapter;
-        this.otlpCorrelationEnricher = otlpCorrelationEnricher;
-        this.greptimeOtlpForwarder = greptimeOtlpForwarder;
-    }
+    private final OtlpGrpcIngestionService otlpGrpcIngestionService;
 
     /**
      * OTLP/HTTP standard endpoint for logs with JSON-encoded Protobuf payload.
      * Content-Type: application/json
      *
      * Response follows OTLP specification:
-     * - Success: HTTP 200 with ExportLogsServiceResponse (JSON encoded)
-     * - Failure: HTTP 400 with google.rpc.Status (JSON encoded)
+     * - Success: HTTP 200 with ExportLogsServiceResponse (JSON or binary encoded)
+     * - Failure: HTTP 400 with google.rpc.Status (JSON or binary encoded)
      *
      * @param content JSON-encoded ExportLogsServiceRequest
      * @return ExportLogsServiceResponse on success, Status on failure
      */
     @Operation(summary = "Ingest OTLP logs (JSON format)")
-    @PostMapping(value = "/v1/logs", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
-    public ResponseEntity<String> ingestJsonLogs(@RequestBody byte[] content, @RequestHeader HttpHeaders requestHeaders) {
-        log.debug("Receive OTLP JSON logs, content length: {}", content == null ? 0 : content.length);
-        try {
-            byte[] enrichedContent = otlpCorrelationEnricher.enrichLogsHttp(
-                    content, requestHeaders, OtlpCorrelationContext.empty());
-            greptimeOtlpForwarder.forwardLogsProtobuf(enrichedContent);
-            otlpLogProtocolAdapter.publishRealtimeSignals(ExportLogsServiceRequest.parseFrom(enrichedContent));
-            return ResponseEntity.ok(toJsonResponse(EMPTY_RESPONSE));
-        } catch (StatusRuntimeException e) {
-            return ResponseEntity.status(toHttpStatus(e))
-                    .body(toJsonErrorResponse(e.getStatus().getDescription()));
-        } catch (IllegalArgumentException e) {
-            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
-                    .body(toJsonErrorResponse(e.getMessage()));
-        } catch (InvalidProtocolBufferException e) {
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
-                    .body(toJsonErrorResponse(e.getMessage()));
-        } catch (Exception e) {
-            // Server-side errors - unexpected failure
-            log.error("Unexpected error ingesting OTLP JSON logs: {}", e.getMessage(), e);
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
-                    .body(toJsonErrorResponse(e.getMessage()));
-        }
+    @PostMapping(
+            value = "/v1/logs",
+            consumes = MediaType.APPLICATION_JSON_VALUE,
+            produces = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF, CONTENT_TYPE_PROTOBUF_ALT}
+    )
+    public ResponseEntity<byte[]> ingestJsonLogs(@RequestBody byte[] content, @RequestHeader HttpHeaders requestHeaders) {
+        return otlpGrpcIngestionService.ingestLogsHttp(content, requestHeaders);
     }
 
     /**
@@ -119,77 +81,12 @@ public class OtlpLogController {
      * @return ExportLogsServiceResponse on success, Status on failure
      */
     @Operation(summary = "Ingest OTLP logs (binary Protobuf format)")
-    @PostMapping(value = "/v1/logs", consumes = CONTENT_TYPE_PROTOBUF, produces = CONTENT_TYPE_PROTOBUF)
+    @PostMapping(
+            value = "/v1/logs",
+            consumes = {CONTENT_TYPE_PROTOBUF, CONTENT_TYPE_PROTOBUF_ALT},
+            produces = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF, CONTENT_TYPE_PROTOBUF_ALT}
+    )
     public ResponseEntity<byte[]> ingestBinaryLogs(@RequestBody byte[] content, @RequestHeader HttpHeaders requestHeaders) {
-        log.debug("Receive OTLP binary logs, content length: {}", content == null ? 0 : content.length);
-        try {
-            byte[] enrichedContent = otlpCorrelationEnricher.enrichLogsHttp(
-                    content, requestHeaders, OtlpCorrelationContext.empty());
-            greptimeOtlpForwarder.forwardLogsProtobuf(enrichedContent);
-            otlpLogProtocolAdapter.publishRealtimeSignals(ExportLogsServiceRequest.parseFrom(enrichedContent));
-            return ResponseEntity.ok(EMPTY_RESPONSE.toByteArray());
-        } catch (StatusRuntimeException e) {
-            return ResponseEntity.status(toHttpStatus(e))
-                    .body(createBinaryErrorResponse(e.getStatus().getDescription()));
-        } catch (IllegalArgumentException e) {
-            // Client-side validation errors - malformed request
-            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
-                    .body(createBinaryErrorResponse(e.getMessage()));
-        } catch (Exception e) {
-            // Server-side errors - unexpected failure
-            log.error("Unexpected error ingesting OTLP binary logs: {}", e.getMessage(), e);
-            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
-                    .body(createBinaryErrorResponse(e.getMessage()));
-        }
-    }
-
-    private String toJsonResponse(ExportLogsServiceResponse response) {
-        try {
-            return JsonFormat.printer().print(response);
-        } catch (InvalidProtocolBufferException e) {
-            log.error("Failed to convert ExportLogsServiceResponse to JSON: {}", e.getMessage(), e);
-            return "{}";
-        }
-    }
-
-    private String toJsonErrorResponse(String message) {
-        Status status = Status.newBuilder()
-                .setMessage(message != null ? message : "Unknown error")
-                .build();
-        try {
-            return JsonFormat.printer().print(status);
-        } catch (InvalidProtocolBufferException e) {
-            return "{\"message\":\"" + escapeJson(message) + "\"}";
-        }
-    }
-
-    /**
-     * Escapes a string value for safe inclusion in JSON.
-     *
-     * @param message the string to escape
-     * @return the escaped string, or empty string if message is null
-     */
-    private String escapeJson(String message) {
-        if (message == null) {
-            return "";
-        }
-
-        char[] escaped = JsonStringEncoder.getInstance().quoteAsString(message);
-        return new String(escaped);
-    }
-
-    private byte[] createBinaryErrorResponse(String message) {
-        return Status.newBuilder()
-                .setMessage(message != null ? message : "Unknown error")
-                .build()
-                .toByteArray();
-    }
-
-    private HttpStatus toHttpStatus(StatusRuntimeException exception) {
-        return switch (exception.getStatus().getCode()) {
-            case INVALID_ARGUMENT -> HttpStatus.BAD_REQUEST;
-            case UNAVAILABLE -> HttpStatus.SERVICE_UNAVAILABLE;
-            default -> HttpStatus.INTERNAL_SERVER_ERROR;
-        };
+        return otlpGrpcIngestionService.ingestLogsHttp(content, requestHeaders);
     }
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpSignalIngestionController.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpSignalIngestionController.java
index a293725c56..83261f8128 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpSignalIngestionController.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpSignalIngestionController.java
@@ -40,13 +40,14 @@ import org.springframework.web.bind.annotation.RestController;
 public class OtlpSignalIngestionController {
 
     private static final String CONTENT_TYPE_PROTOBUF = "application/x-protobuf";
+    private static final String CONTENT_TYPE_PROTOBUF_ALT = "application/protobuf";
     private final OtlpGrpcIngestionService otlpGrpcIngestionService;
 
     @Operation(summary = "Ingest OTLP metrics through unified HertzBeat gateway")
     @PostMapping(
             value = "/v1/metrics",
-            consumes = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF},
-            produces = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF}
+            consumes = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF, CONTENT_TYPE_PROTOBUF_ALT},
+            produces = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF, CONTENT_TYPE_PROTOBUF_ALT}
     )
     public ResponseEntity<byte[]> ingestMetrics(@RequestBody byte[] content, @RequestHeader HttpHeaders requestHeaders) {
         return otlpGrpcIngestionService.ingestMetricsHttp(content, requestHeaders);
@@ -55,8 +56,8 @@ public class OtlpSignalIngestionController {
     @Operation(summary = "Ingest OTLP traces through unified HertzBeat gateway")
     @PostMapping(
             value = "/v1/traces",
-            consumes = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF},
-            produces = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF}
+            consumes = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF, CONTENT_TYPE_PROTOBUF_ALT},
+            produces = {MediaType.APPLICATION_JSON_VALUE, CONTENT_TYPE_PROTOBUF, CONTENT_TYPE_PROTOBUF_ALT}
     )
     public ResponseEntity<byte[]> ingestTraces(@RequestBody byte[] content, @RequestHeader HttpHeaders requestHeaders) {
         return otlpGrpcIngestionService.ingestTracesHttp(content, requestHeaders);
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
index d4398dd193..69bd0c7d92 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationContext.java
@@ -25,6 +25,12 @@ import org.apache.commons.lang3.StringUtils;
  */
 public record OtlpCorrelationContext(String ingestId, String entityId, String workspaceId) {
 
+    public OtlpCorrelationContext {
+        ingestId = StringUtils.trimToNull(ingestId);
+        entityId = StringUtils.trimToNull(entityId);
+        workspaceId = StringUtils.trimToNull(workspaceId);
+    }
+
     public static OtlpCorrelationContext empty() {
         return new OtlpCorrelationContext(null, null, null);
     }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationEnricher.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationEnricher.java
index f08cf60c8a..289241129f 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationEnricher.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpCorrelationEnricher.java
@@ -23,12 +23,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.google.protobuf.InvalidProtocolBufferException;
 import com.google.protobuf.util.JsonFormat;
 import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
+import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest;
 import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
 import io.opentelemetry.proto.common.v1.AnyValue;
 import io.opentelemetry.proto.common.v1.KeyValue;
 import io.opentelemetry.proto.logs.v1.LogRecord;
 import io.opentelemetry.proto.logs.v1.ResourceLogs;
 import io.opentelemetry.proto.logs.v1.ScopeLogs;
+import io.opentelemetry.proto.metrics.v1.ResourceMetrics;
 import io.opentelemetry.proto.resource.v1.Resource;
 import io.opentelemetry.proto.trace.v1.ResourceSpans;
 import java.io.ByteArrayInputStream;
@@ -44,6 +46,7 @@ import java.util.List;
 import java.util.Set;
 import java.util.zip.GZIPInputStream;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.semantic.OtlpResourceSemanticAttributes;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
@@ -57,8 +60,8 @@ public class OtlpCorrelationEnricher {
     public static final String EVENT_ID_ATTRIBUTE = "hertzbeat.event_id";
     public static final String LOG_RECORD_UID_ATTRIBUTE = "log.record.uid";
     public static final String INGEST_ID_ATTRIBUTE = "hertzbeat.ingest_id";
-    public static final String ENTITY_ID_ATTRIBUTE = "hertzbeat.entity_id";
-    public static final String WORKSPACE_ID_ATTRIBUTE = "hertzbeat.workspace_id";
+    public static final String ENTITY_ID_ATTRIBUTE = OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID;
+    public static final String WORKSPACE_ID_ATTRIBUTE = OtlpResourceSemanticAttributes.HERTZBEAT_WORKSPACE_ID;
 
     private static final String CONTENT_ENCODING_GZIP = "gzip";
     private static final Set<String> OTLP_HEX_ID_FIELDS = Set.of("traceId", "spanId", "parentSpanId");
@@ -66,7 +69,7 @@ public class OtlpCorrelationEnricher {
 
     public byte[] enrichLogsHttp(byte[] content, HttpHeaders requestHeaders, OtlpCorrelationContext context) {
         MediaType contentType = requestHeaders == null ? null : requestHeaders.getContentType();
-        byte[] normalizedContent = maybeDecompress(content, requestHeaders);
+        byte[] normalizedContent = maybeDecompress(safeContent(content), requestHeaders, "logs");
         try {
             ExportLogsServiceRequest request;
             if (contentType != null && MediaType.APPLICATION_JSON.includes(contentType)) {
@@ -113,9 +116,26 @@ public class OtlpCorrelationEnricher {
         return requestBuilder.build();
     }
 
+    public ExportMetricsServiceRequest enrichMetrics(ExportMetricsServiceRequest request,
+                                                     OtlpCorrelationContext context) {
+        ExportMetricsServiceRequest source =
+                request == null ? ExportMetricsServiceRequest.getDefaultInstance() : request;
+        OtlpCorrelationContext resolvedContext = context == null ? OtlpCorrelationContext.empty() : context;
+        if (StringUtils.isBlank(resolvedContext.entityId()) && StringUtils.isBlank(resolvedContext.workspaceId())) {
+            return source;
+        }
+        ExportMetricsServiceRequest.Builder requestBuilder = source.toBuilder().clearResourceMetrics();
+        for (ResourceMetrics resourceMetrics : source.getResourceMetricsList()) {
+            ResourceMetrics.Builder resourceBuilder = resourceMetrics.toBuilder();
+            resourceBuilder.setResource(enrichMetricResource(resourceMetrics.getResource(), resolvedContext));
+            requestBuilder.addResourceMetrics(resourceBuilder.build());
+        }
+        return requestBuilder.build();
+    }
+
     public byte[] enrichTracesHttp(byte[] content, HttpHeaders requestHeaders, OtlpCorrelationContext context) {
         MediaType contentType = requestHeaders == null ? null : requestHeaders.getContentType();
-        byte[] normalizedContent = maybeDecompress(content, requestHeaders);
+        byte[] normalizedContent = maybeDecompress(safeContent(content), requestHeaders, "trace");
         try {
             ExportTraceServiceRequest request;
             if (contentType != null && MediaType.APPLICATION_JSON.includes(contentType)) {
@@ -166,10 +186,20 @@ public class OtlpCorrelationEnricher {
                 .build();
     }
 
+    private Resource enrichMetricResource(Resource resource, OtlpCorrelationContext context) {
+        List<KeyValue> attributes = new ArrayList<>(resource.getAttributesList());
+        upsertStringAttributeIfPresent(attributes, ENTITY_ID_ATTRIBUTE, context.entityId());
+        upsertStringAttributeIfPresent(attributes, WORKSPACE_ID_ATTRIBUTE, context.workspaceId());
+        return resource.toBuilder()
+                .clearAttributes()
+                .addAllAttributes(attributes)
+                .build();
+    }
+
     private Resource enrichTraceResource(Resource resource, OtlpCorrelationContext context) {
         List<KeyValue> attributes = new ArrayList<>(resource.getAttributesList());
         addStringAttributeIfMissing(attributes, ENTITY_ID_ATTRIBUTE, context.entityId());
-        addStringAttributeIfMissing(attributes, WORKSPACE_ID_ATTRIBUTE, context.workspaceId());
+        upsertStringAttributeIfPresent(attributes, WORKSPACE_ID_ATTRIBUTE, context.workspaceId());
         return resource.toBuilder()
                 .clearAttributes()
                 .addAllAttributes(attributes)
@@ -287,12 +317,11 @@ public class OtlpCorrelationEnricher {
                 .build();
     }
 
-    private byte[] maybeDecompress(byte[] content, HttpHeaders headers) {
+    private byte[] maybeDecompress(byte[] content, HttpHeaders headers, String signal) {
         if (content == null || content.length == 0 || headers == null) {
             return content;
         }
-        String contentEncoding = headers.getFirst(HttpHeaders.CONTENT_ENCODING);
-        if (!StringUtils.equalsIgnoreCase(contentEncoding, CONTENT_ENCODING_GZIP)) {
+        if (!isGzipEncoded(headers)) {
             return content;
         }
         try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content);
@@ -301,12 +330,35 @@ public class OtlpCorrelationEnricher {
             gzipInputStream.transferTo(outputStream);
             return outputStream.toByteArray();
         } catch (Exception ex) {
-            throw io.grpc.Status.INVALID_ARGUMENT.withDescription("Malformed gzip-compressed OTLP logs payload.")
+            throw io.grpc.Status.INVALID_ARGUMENT.withDescription("Malformed gzip-compressed OTLP "
+                            + signal + " payload.")
                     .withCause(ex)
                     .asRuntimeException();
         }
     }
 
+    private boolean isGzipContentEncoding(String contentEncoding) {
+        String[] encodings = StringUtils.split(contentEncoding, ',');
+        if (encodings == null || encodings.length == 0) {
+            return false;
+        }
+        for (String encoding : encodings) {
+            if (StringUtils.equalsIgnoreCase(StringUtils.trimToEmpty(encoding), CONTENT_ENCODING_GZIP)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean isGzipEncoded(HttpHeaders headers) {
+        List<String> contentEncodings = headers == null ? null : headers.get(HttpHeaders.CONTENT_ENCODING);
+        return contentEncodings != null && contentEncodings.stream().anyMatch(this::isGzipContentEncoding);
+    }
+
+    private byte[] safeContent(byte[] content) {
+        return content == null ? new byte[0] : content;
+    }
+
     private String normalizeOtlpJson(String content) throws InvalidProtocolBufferException {
         try {
             JsonNode root = OBJECT_MAPPER.readTree(content);
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpEntityIdentityResolver.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpEntityIdentityResolver.java
new file mode 100644
index 0000000000..056e3c59f3
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/enricher/OtlpEntityIdentityResolver.java
@@ -0,0 +1,238 @@
+/*
+ * 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.hertzbeat.observability.ingestion.enricher;
+
+import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest;
+import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
+import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
+import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.logs.v1.ResourceLogs;
+import io.opentelemetry.proto.metrics.v1.ResourceMetrics;
+import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
+import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
+import org.apache.hertzbeat.common.observability.gateway.ObservabilityWorkspaceQueryGateway;
+import org.apache.hertzbeat.common.observability.model.EntityCanonicalIdentityRegistry;
+import org.apache.hertzbeat.observability.ingestion.semantic.OtlpResourceSemanticAttributes;
+import org.springframework.stereotype.Service;
+
+/**
+ * Resolves OTLP resource identities to existing HertzBeat entities when there is one clear workspace match.
+ */
+@Slf4j
+@Service
+public class OtlpEntityIdentityResolver {
+
+    private final List<ObservabilityWorkspaceQueryGateway> workspaceQueryGateways;
+
+    public OtlpEntityIdentityResolver(List<ObservabilityWorkspaceQueryGateway> workspaceQueryGateways) {
+        this.workspaceQueryGateways = workspaceQueryGateways == null ? List.of() : List.copyOf(workspaceQueryGateways);
+    }
+
+    public ExportMetricsServiceRequest enrichMetrics(ExportMetricsServiceRequest request, String workspaceId) {
+        if (request == null || request.getResourceMetricsCount() == 0) {
+            return request;
+        }
+        ExportMetricsServiceRequest.Builder requestBuilder = request.toBuilder().clearResourceMetrics();
+        for (ResourceMetrics resourceMetrics : request.getResourceMetricsList()) {
+            requestBuilder.addResourceMetrics(resourceMetrics.toBuilder()
+                    .setResource(enrichResource(resourceMetrics.getResource(), workspaceId))
+                    .build());
+        }
+        return requestBuilder.build();
+    }
+
+    public ExportLogsServiceRequest enrichLogs(ExportLogsServiceRequest request, String workspaceId) {
+        if (request == null || request.getResourceLogsCount() == 0) {
+            return request;
+        }
+        ExportLogsServiceRequest.Builder requestBuilder = request.toBuilder().clearResourceLogs();
+        for (ResourceLogs resourceLogs : request.getResourceLogsList()) {
+            requestBuilder.addResourceLogs(resourceLogs.toBuilder()
+                    .setResource(enrichResource(resourceLogs.getResource(), workspaceId))
+                    .build());
+        }
+        return requestBuilder.build();
+    }
+
+    public ExportTraceServiceRequest enrichTraces(ExportTraceServiceRequest request, String workspaceId) {
+        if (request == null || request.getResourceSpansCount() == 0) {
+            return request;
+        }
+        ExportTraceServiceRequest.Builder requestBuilder = request.toBuilder().clearResourceSpans();
+        for (ResourceSpans resourceSpans : request.getResourceSpansList()) {
+            requestBuilder.addResourceSpans(resourceSpans.toBuilder()
+                    .setResource(enrichResource(resourceSpans.getResource(), workspaceId))
+                    .build());
+        }
+        return requestBuilder.build();
+    }
+
+    public Optional<String> resolveEntityId(Map<String, String> resourceAttributes, String workspaceId) {
+        ObservabilityWorkspaceQueryGateway workspaceQueryGateway = workspaceQueryGateway();
+        String safeWorkspaceId = StringUtils.trimToNull(workspaceId);
+        if (workspaceQueryGateway == null || safeWorkspaceId == null || resourceAttributes == null
+                || resourceAttributes.isEmpty()) {
+            return Optional.empty();
+        }
+        Map<String, String> normalizedIdentities = normalizedCanonicalIdentities(resourceAttributes);
+        if (normalizedIdentities.isEmpty()) {
+            return Optional.empty();
+        }
+        try {
+            List<EntityIdentity> matchedIdentities = nullToEmpty(
+                    workspaceQueryGateway.findIdentitiesByKeysAndNormalizedValues(
+                            normalizedIdentities.keySet(), Set.copyOf(normalizedIdentities.values())));
+            Map<Long, Integer> scores = scoreMatchingIdentities(matchedIdentities, normalizedIdentities);
+            if (scores.isEmpty()) {
+                return Optional.empty();
+            }
+            Map<Long, ObserveEntity> entities =
+                    Optional.ofNullable(workspaceQueryGateway.findEntitiesByIds(scores.keySet()))
+                            .orElseGet(Collections::emptyMap);
+            Map<Long, Integer> workspaceScores = scores.entrySet().stream()
+                    .filter(entry -> workspaceMatches(entities.get(entry.getKey()), safeWorkspaceId))
+                    .collect(LinkedHashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), Map::putAll);
+            if (workspaceScores.isEmpty()) {
+                return Optional.empty();
+            }
+            int topScore = workspaceScores.values().stream().max(Integer::compareTo).orElse(0);
+            List<Long> topEntityIds = workspaceScores.entrySet().stream()
+                    .filter(entry -> entry.getValue() == topScore)
+                    .map(Map.Entry::getKey)
+                    .sorted()
+                    .toList();
+            if (topEntityIds.size() != 1) {
+                return Optional.empty();
+            }
+            return Optional.of(String.valueOf(topEntityIds.getFirst()));
+        } catch (RuntimeException ex) {
+            log.warn("Failed to resolve OTLP resource entity identity for workspace {}: {}",
+                    safeWorkspaceId, ex.toString());
+            return Optional.empty();
+        }
+    }
+
+    private Resource enrichResource(Resource resource, String workspaceId) {
+        Map<String, String> resourceAttributes = stringAttributes(resource.getAttributesList());
+        if (StringUtils.isNotBlank(resourceAttributes.get(OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID))) {
+            return resource;
+        }
+        Optional<String> entityId = resolveEntityId(resourceAttributes, workspaceId);
+        if (entityId.isEmpty()) {
+            return resource;
+        }
+        return resource.toBuilder()
+                .addAttributes(stringAttribute(OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID, entityId.get()))
+                .build();
+    }
+
+    private ObservabilityWorkspaceQueryGateway workspaceQueryGateway() {
+        return workspaceQueryGateways.stream().filter(Objects::nonNull).findFirst().orElse(null);
+    }
+
+    private Map<String, String> normalizedCanonicalIdentities(Map<String, String> resourceAttributes) {
+        Map<String, String> normalized = new LinkedHashMap<>();
+        for (String identityKey : EntityCanonicalIdentityRegistry.CANONICAL_OTEL_RESOURCE_KEYS) {
+            String value = StringUtils.trimToNull(resourceAttributes.get(identityKey));
+            if (value != null) {
+                normalized.put(identityKey, normalizeIdentityValue(value));
+            }
+        }
+        return normalized;
+    }
+
+    private Map<Long, Integer> scoreMatchingIdentities(List<EntityIdentity> matchedIdentities,
+                                                       Map<String, String> normalizedIdentities) {
+        Map<Long, Integer> scores = new LinkedHashMap<>();
+        for (EntityIdentity identity : matchedIdentities) {
+            if (identity == null || identity.getEntityId() == null
+                    || StringUtils.isBlank(identity.getIdentityKey())) {
+                continue;
+            }
+            String expectedValue = normalizedIdentities.get(identity.getIdentityKey());
+            if (expectedValue == null || !expectedValue.equals(normalizedIdentityValue(identity))) {
+                continue;
+            }
+            scores.merge(identity.getEntityId(), identityScore(identity), Integer::sum);
+        }
+        return scores;
+    }
+
+    private int identityScore(EntityIdentity identity) {
+        int score = identity.getPriority() == null
+                ? EntityCanonicalIdentityRegistry.defaultPriority(identity.getIdentityKey())
+                : identity.getPriority();
+        return identity.isPrimaryIdentity() ? score + 10 : score;
+    }
+
+    private String normalizedIdentityValue(EntityIdentity identity) {
+        return StringUtils.defaultIfBlank(identity.getNormalizedValue(),
+                normalizeIdentityValue(identity.getIdentityValue()));
+    }
+
+    private String normalizeIdentityValue(String value) {
+        return value == null ? null : value.trim().toLowerCase(java.util.Locale.ROOT);
+    }
+
+    private boolean workspaceMatches(ObserveEntity entity, String workspaceId) {
+        if (entity == null || StringUtils.isBlank(workspaceId)) {
+            return false;
+        }
+        return AuthTokenScopes.normalizeWorkspaceId(workspaceId)
+                .equals(AuthTokenScopes.normalizeWorkspaceId(entity.getWorkspaceId()));
+    }
+
+    private List<EntityIdentity> nullToEmpty(List<EntityIdentity> identities) {
+        return identities == null ? List.of() : identities;
+    }
+
+    private Map<String, String> stringAttributes(List<KeyValue> attributes) {
+        if (attributes == null || attributes.isEmpty()) {
+            return Map.of();
+        }
+        return attributes.stream()
+                .filter(attribute -> attribute != null && attribute.hasValue()
+                        && attribute.getValue().getValueCase() == AnyValue.ValueCase.STRING_VALUE)
+                .collect(Collectors.toMap(
+                        KeyValue::getKey,
+                        attribute -> attribute.getValue().getStringValue(),
+                        (left, right) -> right,
+                        LinkedHashMap::new));
+    }
+
+    private KeyValue stringAttribute(String key, String value) {
+        return KeyValue.newBuilder()
+                .setKey(key)
+                .setValue(AnyValue.newBuilder().setStringValue(value).build())
+                .build();
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/error/OtlpIngestionBackpressureHeaders.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/error/OtlpIngestionBackpressureHeaders.java
new file mode 100644
index 0000000000..f9f0ea9ba1
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/error/OtlpIngestionBackpressureHeaders.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.hertzbeat.observability.ingestion.error;
+
+import io.grpc.Metadata;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.http.HttpHeaders;
+
+/**
+ * Carries backend backpressure hints across OTLP HTTP and gRPC boundaries.
+ */
+public final class OtlpIngestionBackpressureHeaders {
+
+    private static final long MAX_RETRY_AFTER_SECONDS = 60L;
+
+    public static final Metadata.Key<String> RETRY_AFTER_TRAILER_KEY =
+            Metadata.Key.of("retry-after", Metadata.ASCII_STRING_MARSHALLER);
+
+    private OtlpIngestionBackpressureHeaders() {
+    }
+
+    public static StatusRuntimeException statusRuntimeException(Status status, String description,
+                                                                 HttpHeaders responseHeaders) {
+        Status describedStatus = status.withDescription(description);
+        Metadata trailers = isBackpressureStatus(status) ? retryAfterTrailers(responseHeaders) : null;
+        return trailers == null ? describedStatus.asRuntimeException() : describedStatus.asRuntimeException(trailers);
+    }
+
+    public static String retryAfter(StatusRuntimeException exception) {
+        if (exception == null || !isBackpressureStatus(exception.getStatus())) {
+            return null;
+        }
+        return retryAfter(exception.getTrailers());
+    }
+
+    private static String retryAfter(Metadata trailers) {
+        return trailers == null ? null : legalRetryAfter(trailers.get(RETRY_AFTER_TRAILER_KEY));
+    }
+
+    private static Metadata retryAfterTrailers(HttpHeaders responseHeaders) {
+        String retryAfter = retryAfter(responseHeaders);
+        if (retryAfter == null) {
+            return null;
+        }
+        Metadata trailers = new Metadata();
+        trailers.put(RETRY_AFTER_TRAILER_KEY, retryAfter);
+        return trailers;
+    }
+
+    private static boolean isBackpressureStatus(Status status) {
+        if (status == null) {
+            return false;
+        }
+        Status.Code code = status.getCode();
+        return Status.Code.RESOURCE_EXHAUSTED == code
+                || Status.Code.UNAVAILABLE == code
+                || Status.Code.DEADLINE_EXCEEDED == code;
+    }
+
+    private static String retryAfter(HttpHeaders responseHeaders) {
+        if (responseHeaders == null) {
+            return null;
+        }
+        List<String> values = responseHeaders.get(HttpHeaders.RETRY_AFTER);
+        if (values == null || values.isEmpty()) {
+            return null;
+        }
+        return values.stream()
+                .map(OtlpIngestionBackpressureHeaders::legalRetryAfter)
+                .filter(value -> value != null)
+                .findFirst()
+                .orElse(null);
+    }
+
+    private static String legalRetryAfter(String value) {
+        String trimmed = StringUtils.trimToNull(value);
+        if (trimmed == null) {
+            return null;
+        }
+        if (trimmed.matches("\\d+")) {
+            return cappedRetryAfterSeconds(trimmed);
+        }
+        try {
+            Instant retryAt = ZonedDateTime.parse(trimmed, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
+            long retryAfterSeconds = Duration.between(Instant.now(), retryAt).getSeconds();
+            if (retryAfterSeconds <= 0L) {
+                return "0";
+            }
+            return String.valueOf(Math.min(retryAfterSeconds, MAX_RETRY_AFTER_SECONDS));
+        } catch (DateTimeException ignored) {
+            return null;
+        }
+    }
+
+    private static String cappedRetryAfterSeconds(String value) {
+        try {
+            long seconds = Long.parseLong(value);
+            return String.valueOf(Math.min(seconds, MAX_RETRY_AFTER_SECONDS));
+        } catch (NumberFormatException ignored) {
+            return String.valueOf(MAX_RETRY_AFTER_SECONDS);
+        }
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/error/OtlpIngestionErrorResponseFactory.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/error/OtlpIngestionErrorResponseFactory.java
new file mode 100644
index 0000000000..cde1181785
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/error/OtlpIngestionErrorResponseFactory.java
@@ -0,0 +1,260 @@
+/*
+ * 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.hertzbeat.observability.ingestion.error;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.util.JsonFormat;
+import io.grpc.StatusRuntimeException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+
+/**
+ * Shared OTLP HTTP error response factory for all ingested signals.
+ */
+@Component
+public class OtlpIngestionErrorResponseFactory {
+
+    private static final String CONTENT_TYPE_PROTOBUF = "application/x-protobuf";
+    private static final MediaType MEDIA_TYPE_PROTOBUF = MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF);
+    private static final MediaType MEDIA_TYPE_PROTOBUF_ALT = MediaType.parseMediaType("application/protobuf");
+    private static final String DEFAULT_ERROR_MESSAGE = "OTLP signal ingestion failed.";
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    public ResponseEntity<byte[]> httpErrorResponse(MediaType contentType, StatusRuntimeException exception) {
+        return httpErrorResponse(contentType, List.of(), toHttpStatus(exception), statusCode(exception),
+                statusDescription(exception), OtlpIngestionBackpressureHeaders.retryAfter(exception));
+    }
+
+    public ResponseEntity<byte[]> httpErrorResponse(MediaType contentType, List<MediaType> acceptTypes,
+                                                    StatusRuntimeException exception) {
+        return httpErrorResponse(contentType, acceptTypes, toHttpStatus(exception), statusCode(exception),
+                statusDescription(exception), OtlpIngestionBackpressureHeaders.retryAfter(exception));
+    }
+
+    public ResponseEntity<byte[]> httpErrorResponse(MediaType contentType, HttpStatus status, String message) {
+        return httpErrorResponse(contentType, status, grpcCode(status), message);
+    }
+
+    public ResponseEntity<byte[]> httpErrorResponse(MediaType contentType, List<MediaType> acceptTypes,
+                                                    HttpStatus status, String message) {
+        return httpErrorResponse(contentType, acceptTypes, status, grpcCode(status), message);
+    }
+
+    private ResponseEntity<byte[]> httpErrorResponse(MediaType contentType, HttpStatus status, int grpcCode,
+                                                     String message) {
+        return httpErrorResponse(contentType, List.of(), status, grpcCode, message, null);
+    }
+
+    private ResponseEntity<byte[]> httpErrorResponse(MediaType contentType, List<MediaType> acceptTypes,
+                                                     HttpStatus status, int grpcCode, String message) {
+        return httpErrorResponse(contentType, acceptTypes, status, grpcCode, message, null);
+    }
+
+    private ResponseEntity<byte[]> httpErrorResponse(MediaType contentType, List<MediaType> acceptTypes,
+                                                     HttpStatus status, int grpcCode, String message,
+                                                     String retryAfter) {
+        if (expectsJson(contentType, acceptTypes)) {
+            return withRetryAfter(ResponseEntity.status(status)
+                    .contentType(MediaType.APPLICATION_JSON), retryAfter)
+                    .body(jsonErrorBody(grpcCode, message).getBytes(StandardCharsets.UTF_8));
+        }
+        return withRetryAfter(ResponseEntity.status(status)
+                .contentType(MEDIA_TYPE_PROTOBUF), retryAfter)
+                .body(binaryErrorBody(grpcCode, message));
+    }
+
+    private ResponseEntity.BodyBuilder withRetryAfter(ResponseEntity.BodyBuilder builder, String retryAfter) {
+        String headerValue = StringUtils.trimToNull(retryAfter);
+        return headerValue == null ? builder : builder.header(HttpHeaders.RETRY_AFTER, headerValue);
+    }
+
+    private boolean expectsJson(MediaType contentType, List<MediaType> acceptTypes) {
+        double jsonQuality = negotiatedQuality(acceptTypes, true);
+        double protobufQuality = negotiatedQuality(acceptTypes, false);
+        if (jsonQuality >= 0 || protobufQuality >= 0) {
+            double acceptableJsonQuality = Math.max(jsonQuality, 0.0d);
+            double acceptableProtobufQuality = Math.max(protobufQuality, 0.0d);
+            if (Double.compare(acceptableJsonQuality, acceptableProtobufQuality) == 0) {
+                return acceptableJsonQuality > 0 && isExplicitJsonMediaType(contentType);
+            }
+            return acceptableJsonQuality > acceptableProtobufQuality;
+        }
+        return isExplicitJsonMediaType(contentType);
+    }
+
+    private double negotiatedQuality(List<MediaType> acceptTypes, boolean json) {
+        if (acceptTypes == null || acceptTypes.isEmpty()) {
+            return -1.0d;
+        }
+        double explicitQuality = acceptTypes.stream()
+                .filter(json ? this::isExplicitJsonMediaType : this::isExplicitProtobufMediaType)
+                .mapToDouble(MediaType::getQualityValue)
+                .max()
+                .orElse(-1.0d);
+        if (explicitQuality >= 0) {
+            return explicitQuality;
+        }
+        return acceptTypes.stream()
+                .filter(json ? this::isJsonWildcardMediaType : this::isProtobufWildcardMediaType)
+                .mapToDouble(MediaType::getQualityValue)
+                .max()
+                .orElse(-1.0d);
+    }
+
+    private boolean isJsonWildcardMediaType(MediaType mediaType) {
+        return mediaType != null
+                && !isExplicitJsonMediaType(mediaType)
+                && mediaType.includes(MediaType.APPLICATION_JSON);
+    }
+
+    private boolean isProtobufWildcardMediaType(MediaType mediaType) {
+        return mediaType != null
+                && !isExplicitProtobufMediaType(mediaType)
+                && (mediaType.includes(MEDIA_TYPE_PROTOBUF) || mediaType.includes(MEDIA_TYPE_PROTOBUF_ALT));
+    }
+
+    private boolean isExplicitJsonMediaType(MediaType mediaType) {
+        if (mediaType == null || mediaType.isWildcardType() || mediaType.isWildcardSubtype()) {
+            return false;
+        }
+        return "application".equalsIgnoreCase(mediaType.getType())
+                && ("json".equalsIgnoreCase(mediaType.getSubtype())
+                || mediaType.getSubtype().toLowerCase(Locale.ROOT).endsWith("+json"));
+    }
+
+    private boolean isExplicitProtobufMediaType(MediaType mediaType) {
+        if (mediaType == null || mediaType.isWildcardType() || mediaType.isWildcardSubtype()) {
+            return false;
+        }
+        return "application".equalsIgnoreCase(mediaType.getType())
+                && ("x-protobuf".equalsIgnoreCase(mediaType.getSubtype())
+                || "protobuf".equalsIgnoreCase(mediaType.getSubtype()));
+    }
+
+    public HttpStatus toHttpStatus(StatusRuntimeException exception) {
+        if (exception == null || exception.getStatus() == null) {
+            return HttpStatus.INTERNAL_SERVER_ERROR;
+        }
+        return switch (exception.getStatus().getCode()) {
+            case INVALID_ARGUMENT -> HttpStatus.BAD_REQUEST;
+            case NOT_FOUND -> HttpStatus.NOT_FOUND;
+            case ALREADY_EXISTS, ABORTED -> HttpStatus.CONFLICT;
+            case FAILED_PRECONDITION, OUT_OF_RANGE -> HttpStatus.BAD_REQUEST;
+            case UNAUTHENTICATED -> HttpStatus.UNAUTHORIZED;
+            case PERMISSION_DENIED -> HttpStatus.FORBIDDEN;
+            case RESOURCE_EXHAUSTED -> HttpStatus.TOO_MANY_REQUESTS;
+            case UNIMPLEMENTED -> HttpStatus.NOT_IMPLEMENTED;
+            case UNAVAILABLE -> HttpStatus.SERVICE_UNAVAILABLE;
+            case DEADLINE_EXCEEDED -> HttpStatus.GATEWAY_TIMEOUT;
+            default -> HttpStatus.INTERNAL_SERVER_ERROR;
+        };
+    }
+
+    public String jsonErrorBody(String message) {
+        return jsonErrorBody(io.grpc.Status.Code.UNKNOWN.value(), message);
+    }
+
+    public String jsonErrorBody(int grpcCode, String message) {
+        String errorMessage = defaultErrorMessage(message);
+        try {
+            return JsonFormat.printer().print(statusPayload(grpcCode, errorMessage));
+        } catch (InvalidProtocolBufferException ex) {
+            try {
+                return OBJECT_MAPPER.writeValueAsString(Map.of("code", grpcCode, "message", errorMessage));
+            } catch (Exception ignored) {
+                return "{\"code\":" + grpcCode + ",\"message\":\"" + errorMessage + "\"}";
+            }
+        }
+    }
+
+    public byte[] binaryErrorBody(String message) {
+        return binaryErrorBody(io.grpc.Status.Code.UNKNOWN.value(), message);
+    }
+
+    public byte[] binaryErrorBody(int grpcCode, String message) {
+        return statusPayload(grpcCode, defaultErrorMessage(message)).toByteArray();
+    }
+
+    public String defaultErrorMessage(String message) {
+        return StringUtils.defaultIfBlank(message, DEFAULT_ERROR_MESSAGE);
+    }
+
+    private String statusDescription(StatusRuntimeException exception) {
+        return exception == null || exception.getStatus() == null
+                ? DEFAULT_ERROR_MESSAGE
+                : exception.getStatus().getDescription();
+    }
+
+    private int statusCode(StatusRuntimeException exception) {
+        if (exception == null || exception.getStatus() == null) {
+            return io.grpc.Status.Code.UNKNOWN.value();
+        }
+        return exception.getStatus().getCode().value();
+    }
+
+    private int grpcCode(HttpStatus status) {
+        if (status == null) {
+            return io.grpc.Status.Code.UNKNOWN.value();
+        }
+        return switch (status) {
+            case BAD_REQUEST -> io.grpc.Status.Code.INVALID_ARGUMENT.value();
+            case NOT_ACCEPTABLE -> io.grpc.Status.Code.INVALID_ARGUMENT.value();
+            case UNSUPPORTED_MEDIA_TYPE -> io.grpc.Status.Code.INVALID_ARGUMENT.value();
+            case UNPROCESSABLE_ENTITY -> io.grpc.Status.Code.INVALID_ARGUMENT.value();
+            case NOT_FOUND -> io.grpc.Status.Code.NOT_FOUND.value();
+            case CONFLICT -> io.grpc.Status.Code.ABORTED.value();
+            case LOCKED -> io.grpc.Status.Code.ABORTED.value();
+            case PRECONDITION_FAILED -> io.grpc.Status.Code.FAILED_PRECONDITION.value();
+            case PRECONDITION_REQUIRED -> io.grpc.Status.Code.FAILED_PRECONDITION.value();
+            case REQUESTED_RANGE_NOT_SATISFIABLE -> io.grpc.Status.Code.OUT_OF_RANGE.value();
+            case UNAUTHORIZED -> io.grpc.Status.Code.UNAUTHENTICATED.value();
+            case FORBIDDEN -> io.grpc.Status.Code.PERMISSION_DENIED.value();
+            case REQUEST_TIMEOUT -> io.grpc.Status.Code.DEADLINE_EXCEEDED.value();
+            case METHOD_NOT_ALLOWED -> io.grpc.Status.Code.UNIMPLEMENTED.value();
+            case PAYLOAD_TOO_LARGE -> io.grpc.Status.Code.RESOURCE_EXHAUSTED.value();
+            case REQUEST_HEADER_FIELDS_TOO_LARGE -> io.grpc.Status.Code.RESOURCE_EXHAUSTED.value();
+            case TOO_MANY_REQUESTS -> io.grpc.Status.Code.RESOURCE_EXHAUSTED.value();
+            case TOO_EARLY -> io.grpc.Status.Code.UNAVAILABLE.value();
+            case BAD_GATEWAY -> io.grpc.Status.Code.UNAVAILABLE.value();
+            case SERVICE_UNAVAILABLE -> io.grpc.Status.Code.UNAVAILABLE.value();
+            case GATEWAY_TIMEOUT -> io.grpc.Status.Code.DEADLINE_EXCEEDED.value();
+            case NOT_IMPLEMENTED -> io.grpc.Status.Code.UNIMPLEMENTED.value();
+            case INSUFFICIENT_STORAGE -> io.grpc.Status.Code.RESOURCE_EXHAUSTED.value();
+            case INTERNAL_SERVER_ERROR -> io.grpc.Status.Code.INTERNAL.value();
+            default -> status.is5xxServerError()
+                    ? io.grpc.Status.Code.INTERNAL.value()
+                    : io.grpc.Status.Code.UNKNOWN.value();
+        };
+    }
+
+    private com.google.rpc.Status statusPayload(int grpcCode, String message) {
+        return com.google.rpc.Status.newBuilder()
+                .setCode(Math.max(0, grpcCode))
+                .setMessage(message)
+                .build();
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeApmFlowInitializer.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeApmFlowInitializer.java
index 0439813de4..933aa49ec9 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeApmFlowInitializer.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeApmFlowInitializer.java
@@ -17,14 +17,21 @@
 
 package org.apache.hertzbeat.observability.ingestion.forwarder;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Set;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.retry.OtlpIngestionRetryService;
 import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.context.event.ApplicationReadyEvent;
 import org.springframework.context.event.EventListener;
@@ -35,7 +42,7 @@ import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Component;
-import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.HttpClientErrorException;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.util.UriUtils;
 
@@ -50,19 +57,32 @@ public class GreptimeApmFlowInitializer {
     public static final String APM_FLOW_RESOURCE = "greptime/flows/hertzbeat_apm_red_1m.sql";
 
     private static final String SQL_PATH = "/v1/sql";
+    private static final String DEFAULT_GREPTIME_DB_NAME = "public";
+    private static final String TRACE_TABLE = "hzb_traces";
+    private static final String RESOURCE_ATTRIBUTES_COLUMN = "resource_attributes";
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
     private final RestTemplate restTemplate;
     private final ObjectProvider<GreptimeProperties> greptimePropertiesProvider;
+    private final OtlpIngestionRetryService retryService;
 
+    GreptimeApmFlowInitializer(RestTemplate restTemplate,
+                               ObjectProvider<GreptimeProperties> greptimePropertiesProvider) {
+        this(restTemplate, greptimePropertiesProvider, new OtlpIngestionRetryService());
+    }
+
+    @Autowired
     public GreptimeApmFlowInitializer(RestTemplate restTemplate,
-                                      ObjectProvider<GreptimeProperties> greptimePropertiesProvider) {
+                                      ObjectProvider<GreptimeProperties> greptimePropertiesProvider,
+                                      OtlpIngestionRetryService retryService) {
         this.restTemplate = restTemplate;
         this.greptimePropertiesProvider = greptimePropertiesProvider;
+        this.retryService = retryService == null ? new OtlpIngestionRetryService() : retryService;
     }
 
     @EventListener(ApplicationReadyEvent.class)
     public void initialize() {
-        GreptimeProperties greptimeProperties = greptimePropertiesProvider.getIfAvailable();
+        GreptimeProperties greptimeProperties = greptimePropertiesOrNull();
         if (greptimeProperties == null || !greptimeProperties.enabled()
                 || StringUtils.isBlank(greptimeProperties.httpEndpoint())) {
             log.debug("[observability greptime-apm-flow] skip initialization because Greptime is disabled.");
@@ -70,27 +90,64 @@ public class GreptimeApmFlowInitializer {
         }
         try {
             List<String> statements = readFlowStatements();
+            Set<String> traceTableColumns = null;
             for (String statement : statements) {
-                executeStatement(greptimeProperties, statement);
+                try {
+                    if (!executeStatement(greptimeProperties, statement)) {
+                        return;
+                    }
+                } catch (HttpClientErrorException.BadRequest ex) {
+                    if (!isTraceResourceAttributeSchemaFailure(statement, ex)) {
+                        throw ex;
+                    }
+                    if (traceTableColumns == null) {
+                        traceTableColumns = traceTableColumns(greptimeProperties);
+                    }
+                    String adaptedStatement = adaptTraceResourceAttributeExpressions(statement, traceTableColumns);
+                    if (statement.equals(adaptedStatement)) {
+                        throw ex;
+                    }
+                    if (!executeStatement(greptimeProperties, adaptedStatement)) {
+                        return;
+                    }
+                }
             }
             log.info("[observability greptime-apm-flow] initialized Greptime APM RED Flow.");
-        } catch (IOException | RestClientException ex) {
+        } catch (IOException | RuntimeException ex) {
             log.warn("[observability greptime-apm-flow] failed to initialize Greptime APM RED Flow: {}",
                     ex.getMessage(), ex);
         }
     }
 
-    private void executeStatement(GreptimeProperties greptimeProperties, String statement) {
-        ResponseEntity<String> response = restTemplate.exchange(
-                endpoint(greptimeProperties),
-                HttpMethod.POST,
-                sqlRequest(greptimeProperties, statement),
-                String.class
-        );
+    private GreptimeProperties greptimePropertiesOrNull() {
+        try {
+            return greptimePropertiesProvider.getIfAvailable();
+        } catch (RuntimeException ex) {
+            log.warn("[observability greptime-apm-flow] failed to resolve Greptime properties: {}",
+                    ex.getMessage(), ex);
+            return null;
+        }
+    }
+
+    private boolean executeStatement(GreptimeProperties greptimeProperties, String statement) {
+        ResponseEntity<String> response = retryService.execute(() -> restTemplate.exchange(
+                        endpoint(greptimeProperties),
+                        HttpMethod.POST,
+                        sqlRequest(greptimeProperties, statement),
+                        String.class
+                ),
+                responseEntity -> responseEntity == null
+                        || retryService.isRetryableStatus(responseEntity.getStatusCode()));
+        if (response == null) {
+            log.warn("[observability greptime-apm-flow] Greptime SQL statement returned no response.");
+            return true;
+        }
         if (!response.getStatusCode().is2xxSuccessful()) {
             log.warn("[observability greptime-apm-flow] Greptime SQL statement returned status {}.",
                     response.getStatusCode());
+            return false;
         }
+        return true;
     }
 
     private HttpEntity<String> sqlRequest(GreptimeProperties greptimeProperties, String sql) {
@@ -110,18 +167,99 @@ public class GreptimeApmFlowInitializer {
                 .toList();
     }
 
+    private boolean isTraceResourceAttributeSchemaFailure(String statement, HttpClientErrorException ex) {
+        if (!StringUtils.containsIgnoreCase(statement, "CREATE FLOW IF NOT EXISTS hertzbeat_apm_red_1m_flow")) {
+            return false;
+        }
+        String responseBody = ex.getResponseBodyAsString(StandardCharsets.UTF_8);
+        return StringUtils.containsIgnoreCase(ex.getMessage(), "resource_attributes")
+                || StringUtils.containsIgnoreCase(responseBody, "resource_attributes");
+    }
+
+    private Set<String> traceTableColumns(GreptimeProperties greptimeProperties) {
+        try {
+            ResponseEntity<String> response = restTemplate.exchange(
+                    endpoint(greptimeProperties),
+                    HttpMethod.POST,
+                    sqlRequest(greptimeProperties, "DESC " + TRACE_TABLE),
+                    String.class);
+            if (response == null || !response.getStatusCode().is2xxSuccessful()
+                    || StringUtils.isBlank(response.getBody())) {
+                return Collections.singleton(RESOURCE_ATTRIBUTES_COLUMN);
+            }
+            return parseTraceTableColumns(response.getBody());
+        } catch (RuntimeException | IOException ex) {
+            log.debug("[observability greptime-apm-flow] failed to inspect Greptime trace schema: {}",
+                    ex.getMessage(), ex);
+            return Collections.singleton(RESOURCE_ATTRIBUTES_COLUMN);
+        }
+    }
+
+    private Set<String> parseTraceTableColumns(String responseBody) throws IOException {
+        Set<String> columns = new LinkedHashSet<>();
+        JsonNode root = OBJECT_MAPPER.readTree(responseBody);
+        for (JsonNode output : root.path("output")) {
+            JsonNode rows = output.path("records").path("rows");
+            if (!rows.isArray()) {
+                continue;
+            }
+            for (JsonNode row : rows) {
+                if (row.isArray() && !row.isEmpty() && StringUtils.isNotBlank(row.get(0).asText())) {
+                    columns.add(row.get(0).asText());
+                }
+            }
+        }
+        if (columns.isEmpty()) {
+            return Collections.singleton(RESOURCE_ATTRIBUTES_COLUMN);
+        }
+        return Collections.unmodifiableSet(columns);
+    }
+
+    private String adaptTraceResourceAttributeExpressions(String statement, Set<String> traceTableColumns) {
+        String adaptedStatement = statement;
+        for (String key : List.of(
+                "hertzbeat.workspace_id",
+                "hertzbeat.entity_id",
+                "deployment.environment.name",
+                "service.namespace")) {
+            adaptedStatement = adaptedStatement.replace(
+                    "json_get_string(resource_attributes, '$[\"" + key + "\"]')",
+                    resourceAttributeExpression(traceTableColumns, key));
+        }
+        return adaptedStatement;
+    }
+
+    private String resourceAttributeExpression(Set<String> traceTableColumns, String key) {
+        String flattenedColumn = RESOURCE_ATTRIBUTES_COLUMN + "." + key;
+        if (traceTableColumns.contains(flattenedColumn)) {
+            return quoteIdentifier(flattenedColumn);
+        }
+        if (traceTableColumns.contains(RESOURCE_ATTRIBUTES_COLUMN)) {
+            return "json_get_string(resource_attributes, '$[\"" + key.replace("\"", "\\\"") + "\"]')";
+        }
+        return "NULL";
+    }
+
+    private String quoteIdentifier(String column) {
+        return "\"" + column.replace("\"", "\"\"") + "\"";
+    }
+
     private void addAuthenticationHeader(HttpHeaders headers, GreptimeProperties greptimeProperties) {
-        if (StringUtils.isBlank(greptimeProperties.username()) || StringUtils.isBlank(greptimeProperties.password())) {
+        String username = StringUtils.trimToNull(greptimeProperties.username());
+        String password = StringUtils.trimToNull(greptimeProperties.password());
+        if (username == null || password == null) {
             return;
         }
-        headers.setBasicAuth(greptimeProperties.username(), greptimeProperties.password(), StandardCharsets.UTF_8);
+        headers.setBasicAuth(username, password, StandardCharsets.UTF_8);
     }
 
     private String endpoint(GreptimeProperties greptimeProperties) {
-        String endpoint = StringUtils.removeEnd(greptimeProperties.httpEndpoint(), "/") + SQL_PATH;
-        if (StringUtils.isNotBlank(greptimeProperties.database())) {
-            endpoint += "?db=" + UriUtils.encodeQueryParam(greptimeProperties.database(), StandardCharsets.UTF_8);
-        }
-        return endpoint;
+        String endpoint = StringUtils.stripEnd(StringUtils.trim(greptimeProperties.httpEndpoint()), "/") + SQL_PATH;
+        return endpoint + "?db=" + UriUtils.encodeQueryParam(database(greptimeProperties.database()),
+                StandardCharsets.UTF_8);
+    }
+
+    private String database(String configuredDatabase) {
+        return StringUtils.defaultIfBlank(StringUtils.trim(configuredDatabase), DEFAULT_GREPTIME_DB_NAME);
     }
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeLogPipelineInitializer.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeLogPipelineInitializer.java
index b12990b46b..f44340a149 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeLogPipelineInitializer.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeLogPipelineInitializer.java
@@ -23,8 +23,10 @@ import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.retry.OtlpIngestionRetryService;
 import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.context.event.ApplicationReadyEvent;
 import org.springframework.context.event.EventListener;
@@ -58,16 +60,25 @@ public class GreptimeLogPipelineInitializer {
 
     private final RestTemplate restTemplate;
     private final ObjectProvider<GreptimeProperties> greptimePropertiesProvider;
+    private final OtlpIngestionRetryService retryService;
 
     public GreptimeLogPipelineInitializer(RestTemplate restTemplate,
                                           ObjectProvider<GreptimeProperties> greptimePropertiesProvider) {
+        this(restTemplate, greptimePropertiesProvider, new OtlpIngestionRetryService());
+    }
+
+    @Autowired
+    public GreptimeLogPipelineInitializer(RestTemplate restTemplate,
+                                          ObjectProvider<GreptimeProperties> greptimePropertiesProvider,
+                                          OtlpIngestionRetryService retryService) {
         this.restTemplate = restTemplate;
         this.greptimePropertiesProvider = greptimePropertiesProvider;
+        this.retryService = retryService == null ? new OtlpIngestionRetryService() : retryService;
     }
 
     @EventListener(ApplicationReadyEvent.class)
     public void initialize() {
-        GreptimeProperties greptimeProperties = greptimePropertiesProvider.getIfAvailable();
+        GreptimeProperties greptimeProperties = greptimePropertiesOrNull();
         if (greptimeProperties == null || !greptimeProperties.enabled()
                 || StringUtils.isBlank(greptimeProperties.httpEndpoint())) {
             log.debug("[observability greptime-log] skip pipeline upload because Greptime is disabled.");
@@ -80,12 +91,19 @@ public class GreptimeLogPipelineInitializer {
                         GreptimeOtlpForwarder.LOG_PIPELINE_NAME);
                 return;
             }
-            ResponseEntity<String> response = restTemplate.exchange(
-                    endpoint(greptimeProperties.httpEndpoint()),
-                    HttpMethod.POST,
-                    pipelineUploadRequest(greptimeProperties, pipeline),
-                    String.class
-            );
+            ResponseEntity<String> response = retryService.execute(() -> restTemplate.exchange(
+                            endpoint(greptimeProperties.httpEndpoint()),
+                            HttpMethod.POST,
+                            pipelineUploadRequest(greptimeProperties, pipeline),
+                            String.class
+                    ),
+                    retryableResponse -> retryableResponse == null
+                            || retryService.isRetryableStatus(retryableResponse.getStatusCode()));
+            if (response == null) {
+                log.warn("[observability greptime-log] Greptime returned no response while uploading log pipeline {}.",
+                        GreptimeOtlpForwarder.LOG_PIPELINE_NAME);
+                return;
+            }
             if (response.getStatusCode().is2xxSuccessful()) {
                 log.info("[observability greptime-log] uploaded Greptime log pipeline {}.",
                         GreptimeOtlpForwarder.LOG_PIPELINE_NAME);
@@ -93,20 +111,34 @@ public class GreptimeLogPipelineInitializer {
             }
             log.warn("[observability greptime-log] failed to upload Greptime log pipeline {}, status {}.",
                     GreptimeOtlpForwarder.LOG_PIPELINE_NAME, response.getStatusCode());
-        } catch (IOException | RestClientException ex) {
+        } catch (IOException | RuntimeException ex) {
             log.warn("[observability greptime-log] failed to upload Greptime log pipeline {}: {}",
                     GreptimeOtlpForwarder.LOG_PIPELINE_NAME, ex.getMessage(), ex);
         }
     }
 
+    private GreptimeProperties greptimePropertiesOrNull() {
+        try {
+            return greptimePropertiesProvider.getIfAvailable();
+        } catch (RuntimeException ex) {
+            log.warn("[observability greptime-log] failed to resolve Greptime properties: {}", ex.getMessage(), ex);
+            return null;
+        }
+    }
+
     private boolean latestPipelineMatches(GreptimeProperties greptimeProperties, String bundledPipeline) {
         try {
-            ResponseEntity<String> response = restTemplate.exchange(
-                    endpoint(greptimeProperties.httpEndpoint()),
-                    HttpMethod.GET,
-                    new HttpEntity<Void>(headers(greptimeProperties)),
-                    String.class
-            );
+            ResponseEntity<String> response = retryService.execute(() -> restTemplate.exchange(
+                            endpoint(greptimeProperties.httpEndpoint()),
+                            HttpMethod.GET,
+                            new HttpEntity<Void>(headers(greptimeProperties)),
+                            String.class
+                    ),
+                    retryableResponse -> retryableResponse == null
+                            || retryService.isRetryableStatus(retryableResponse.getStatusCode()));
+            if (response == null) {
+                return false;
+            }
             if (!response.getStatusCode().is2xxSuccessful() || StringUtils.isBlank(response.getBody())) {
                 return false;
             }
@@ -166,13 +198,15 @@ public class GreptimeLogPipelineInitializer {
     }
 
     private void addAuthenticationHeader(HttpHeaders headers, GreptimeProperties greptimeProperties) {
-        if (StringUtils.isBlank(greptimeProperties.username()) || StringUtils.isBlank(greptimeProperties.password())) {
+        String username = StringUtils.trimToNull(greptimeProperties.username());
+        String password = StringUtils.trimToNull(greptimeProperties.password());
+        if (username == null || password == null) {
             return;
         }
-        headers.setBasicAuth(greptimeProperties.username(), greptimeProperties.password(), StandardCharsets.UTF_8);
+        headers.setBasicAuth(username, password, StandardCharsets.UTF_8);
     }
 
     private String endpoint(String baseEndpoint) {
-        return StringUtils.removeEnd(baseEndpoint, "/") + LOG_PIPELINE_PATH;
+        return StringUtils.stripEnd(StringUtils.trim(baseEndpoint), "/") + LOG_PIPELINE_PATH;
     }
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeOtlpForwarder.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeOtlpForwarder.java
index 1cf87ec385..3a555260f4 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeOtlpForwarder.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeOtlpForwarder.java
@@ -22,7 +22,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.google.protobuf.InvalidProtocolBufferException;
 import com.google.protobuf.util.JsonFormat;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
 import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
+import io.opentelemetry.proto.common.v1.AnyValue;
 import io.opentelemetry.proto.common.v1.KeyValue;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -34,15 +37,22 @@ import java.util.Set;
 import java.util.zip.GZIPInputStream;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.error.OtlpIngestionBackpressureHeaders;
+import org.apache.hertzbeat.observability.ingestion.redaction.OtlpIngestionRedactionService;
+import org.apache.hertzbeat.observability.ingestion.retry.OtlpIngestionRetryService;
 import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
 import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpStatusCodeException;
 import org.springframework.web.client.RestClientException;
 import org.springframework.web.client.RestTemplate;
 
@@ -69,11 +79,24 @@ public class GreptimeOtlpForwarder {
 
     private final RestTemplate restTemplate;
     private final ObjectProvider<GreptimeProperties> greptimePropertiesProvider;
+    private final OtlpIngestionRedactionService redactionService;
+    private final OtlpIngestionRetryService retryService;
 
+    @Autowired
     public GreptimeOtlpForwarder(RestTemplate restTemplate,
-                                 ObjectProvider<GreptimeProperties> greptimePropertiesProvider) {
+                                 ObjectProvider<GreptimeProperties> greptimePropertiesProvider,
+                                 OtlpIngestionRedactionService redactionService,
+                                 OtlpIngestionRetryService retryService) {
         this.restTemplate = restTemplate;
         this.greptimePropertiesProvider = greptimePropertiesProvider;
+        this.redactionService = redactionService;
+        this.retryService = retryService;
+    }
+
+    public GreptimeOtlpForwarder(RestTemplate restTemplate,
+                                 ObjectProvider<GreptimeProperties> greptimePropertiesProvider) {
+        this(restTemplate, greptimePropertiesProvider, new OtlpIngestionRedactionService(),
+                new OtlpIngestionRetryService());
     }
 
     public ResponseEntity<byte[]> forwardLogsHttp(byte[] content, HttpHeaders requestHeaders) {
@@ -83,7 +106,7 @@ public class GreptimeOtlpForwarder {
     }
 
     public ResponseEntity<byte[]> forwardLogsProtobuf(byte[] protobufContent) {
-        return post(LOGS_PATH, sanitizeLogsForGreptimeNative(protobufContent), logHeaders());
+        return post(LOGS_PATH, sanitizeLogsForGreptimeNative(safeContent(protobufContent)), logHeaders());
     }
 
     public byte[] forwardLogsGrpc(ExportLogsServiceRequest request) {
@@ -96,7 +119,7 @@ public class GreptimeOtlpForwarder {
     }
 
     private ResponseEntity<byte[]> post(String path, byte[] content, HttpHeaders headers) {
-        GreptimeProperties greptimeProperties = greptimePropertiesProvider.getIfAvailable();
+        GreptimeProperties greptimeProperties = greptimePropertiesOrUnavailable();
         if (greptimeProperties == null || !greptimeProperties.enabled()
                 || StringUtils.isBlank(greptimeProperties.httpEndpoint())) {
             throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend is not configured.")
@@ -105,17 +128,24 @@ public class GreptimeOtlpForwarder {
         addGreptimeCommonHeaders(headers, greptimeProperties);
         addAuthenticationHeader(headers, greptimeProperties);
         try {
-            ResponseEntity<byte[]> response = restTemplate.exchange(
+            ResponseEntity<byte[]> response = retryService.execute(() -> restTemplate.exchange(
                     endpoint(greptimeProperties.httpEndpoint(), path),
                     HttpMethod.POST,
                     new HttpEntity<>(content, headers),
                     byte[].class
-            );
-            if (!response.getStatusCode().is2xxSuccessful()) {
-                throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend returned " + response.getStatusCode())
+            ), retryableResponse -> retryableResponse == null
+                    || retryService.isRetryableStatus(retryableResponse.getStatusCode()));
+            if (response == null) {
+                throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend returned no response.")
                         .asRuntimeException();
             }
+            if (!response.getStatusCode().is2xxSuccessful()) {
+                throw backendStatusException(response.getStatusCode(), response.getHeaders());
+            }
             return response;
+        } catch (HttpStatusCodeException ex) {
+            log.error("Failed to forward OTLP payload to Greptime path {}: {}", path, ex.getMessage(), ex);
+            throw backendStatusException(ex.getStatusCode(), ex.getResponseHeaders());
         } catch (RestClientException ex) {
             log.error("Failed to forward OTLP payload to Greptime path {}: {}", path, ex.getMessage(), ex);
             throw io.grpc.Status.UNAVAILABLE
@@ -125,6 +155,17 @@ public class GreptimeOtlpForwarder {
         }
     }
 
+    private GreptimeProperties greptimePropertiesOrUnavailable() {
+        try {
+            return greptimePropertiesProvider.getIfAvailable();
+        } catch (RuntimeException ex) {
+            log.warn("Failed to resolve Greptime OTLP log backend properties: {}", ex.toString());
+            throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend is not configured.")
+                    .withCause(ex)
+                    .asRuntimeException();
+        }
+    }
+
     private HttpHeaders logHeaders() {
         HttpHeaders headers = new HttpHeaders();
         headers.setContentType(MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF));
@@ -135,21 +176,110 @@ public class GreptimeOtlpForwarder {
     }
 
     private void addGreptimeCommonHeaders(HttpHeaders headers, GreptimeProperties greptimeProperties) {
-        headers.set(GREPTIME_DB_NAME_HEADER,
-                StringUtils.defaultIfBlank(greptimeProperties.database(), DEFAULT_GREPTIME_DB_NAME));
+        headers.set(GREPTIME_DB_NAME_HEADER, database(greptimeProperties.database()));
     }
 
     private void addAuthenticationHeader(HttpHeaders headers, GreptimeProperties greptimeProperties) {
-        if (StringUtils.isBlank(greptimeProperties.username()) || StringUtils.isBlank(greptimeProperties.password())) {
+        String username = StringUtils.trimToNull(greptimeProperties.username());
+        String password = StringUtils.trimToNull(greptimeProperties.password());
+        if (username == null || password == null) {
             return;
         }
-        String credentials = greptimeProperties.username() + ":" + greptimeProperties.password();
+        String credentials = username + ":" + password;
         String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
         headers.set(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials);
     }
 
+    private StatusRuntimeException backendStatusException(HttpStatusCode statusCode) {
+        return backendStatusException(statusCode, null);
+    }
+
+    private StatusRuntimeException backendStatusException(HttpStatusCode statusCode, HttpHeaders responseHeaders) {
+        Status status = backendGrpcStatus(statusCode);
+        return OtlpIngestionBackpressureHeaders.statusRuntimeException(
+                status, "OTLP backend returned " + statusCode, responseHeaders);
+    }
+
+    private Status backendGrpcStatus(HttpStatusCode statusCode) {
+        if (statusCode == null) {
+            return Status.UNAVAILABLE;
+        }
+        if (statusCode.value() == HttpStatus.UNAUTHORIZED.value()) {
+            return Status.UNAUTHENTICATED;
+        }
+        if (statusCode.value() == HttpStatus.FORBIDDEN.value()) {
+            return Status.PERMISSION_DENIED;
+        }
+        if (statusCode.value() == HttpStatus.BAD_REQUEST.value()) {
+            return Status.INVALID_ARGUMENT;
+        }
+        if (statusCode.value() == HttpStatus.NOT_ACCEPTABLE.value()) {
+            return Status.INVALID_ARGUMENT;
+        }
+        if (statusCode.value() == HttpStatus.UNSUPPORTED_MEDIA_TYPE.value()) {
+            return Status.INVALID_ARGUMENT;
+        }
+        if (statusCode.value() == HttpStatus.UNPROCESSABLE_ENTITY.value()) {
+            return Status.INVALID_ARGUMENT;
+        }
+        if (statusCode.value() == HttpStatus.NOT_FOUND.value()) {
+            return Status.NOT_FOUND;
+        }
+        if (statusCode.value() == HttpStatus.CONFLICT.value()) {
+            return Status.ABORTED;
+        }
+        if (statusCode.value() == HttpStatus.LOCKED.value()) {
+            return Status.ABORTED;
+        }
+        if (statusCode.value() == HttpStatus.PRECONDITION_FAILED.value()) {
+            return Status.FAILED_PRECONDITION;
+        }
+        if (statusCode.value() == HttpStatus.PRECONDITION_REQUIRED.value()) {
+            return Status.FAILED_PRECONDITION;
+        }
+        if (statusCode.value() == HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value()) {
+            return Status.OUT_OF_RANGE;
+        }
+        if (statusCode.value() == HttpStatus.METHOD_NOT_ALLOWED.value()) {
+            return Status.UNIMPLEMENTED;
+        }
+        if (statusCode.value() == HttpStatus.NOT_IMPLEMENTED.value()) {
+            return Status.UNIMPLEMENTED;
+        }
+        if (statusCode.value() == HttpStatus.PAYLOAD_TOO_LARGE.value()) {
+            return Status.RESOURCE_EXHAUSTED;
+        }
+        if (statusCode.value() == HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE.value()) {
+            return Status.RESOURCE_EXHAUSTED;
+        }
+        if (statusCode.value() == HttpStatus.INSUFFICIENT_STORAGE.value()) {
+            return Status.RESOURCE_EXHAUSTED;
+        }
+        if (statusCode.value() == HttpStatus.TOO_MANY_REQUESTS.value()) {
+            return Status.RESOURCE_EXHAUSTED;
+        }
+        if (statusCode.value() == HttpStatus.TOO_EARLY.value()) {
+            return Status.UNAVAILABLE;
+        }
+        if (statusCode.value() == HttpStatus.REQUEST_TIMEOUT.value()
+                || statusCode.value() == HttpStatus.GATEWAY_TIMEOUT.value()) {
+            return Status.DEADLINE_EXCEEDED;
+        }
+        if (statusCode.value() == HttpStatus.BAD_GATEWAY.value()
+                || statusCode.value() == HttpStatus.SERVICE_UNAVAILABLE.value()) {
+            return Status.UNAVAILABLE;
+        }
+        if (statusCode.is4xxClientError()) {
+            return Status.INTERNAL;
+        }
+        if (statusCode.is5xxServerError()) {
+            return Status.INTERNAL;
+        }
+        return Status.UNAVAILABLE;
+    }
+
     private byte[] normalizeLogsRequestBody(byte[] content, MediaType contentType, HttpHeaders requestHeaders) {
-        byte[] normalizedContent = maybeDecompress(content, requestHeaders);
+        byte[] normalizedContent = maybeDecompress(safeContent(content), requestHeaders);
         if (contentType == null || !MediaType.APPLICATION_JSON.includes(contentType)) {
             return normalizedContent;
         }
@@ -186,6 +316,7 @@ public class GreptimeOtlpForwarder {
                 for (int logIndex = 0; logIndex < scopeLogsBuilder.getLogRecordsCount(); logIndex++) {
                     var logRecordBuilder = scopeLogsBuilder.getLogRecordsBuilder(logIndex);
                     changed |= sanitizeKeyValueAttributes(logRecordBuilder.getAttributesBuilderList());
+                    changed |= redactLogBody(logRecordBuilder.getBodyBuilder());
                 }
             }
         }
@@ -199,16 +330,66 @@ public class GreptimeOtlpForwarder {
                 attributeBuilder.setKey(SAFE_TIMESTAMP_ATTRIBUTE);
                 changed = true;
             }
+            if (redactionService.isSensitiveKey(attributeBuilder.getKey())) {
+                attributeBuilder.setValue(redactedAnyValue());
+                changed = true;
+                continue;
+            }
+            changed |= redactAnyValue(attributeBuilder.getValueBuilder());
         }
         return changed;
     }
 
+    private boolean redactLogBody(AnyValue.Builder bodyBuilder) {
+        if (bodyBuilder == null) {
+            return false;
+        }
+        return redactAnyValue(bodyBuilder);
+    }
+
+    private boolean redactAnyValue(AnyValue.Builder valueBuilder) {
+        if (valueBuilder == null) {
+            return false;
+        }
+        if (valueBuilder.getValueCase() == AnyValue.ValueCase.STRING_VALUE) {
+            String original = valueBuilder.getStringValue();
+            String redacted = redactionService.redactText(original);
+            if (!StringUtils.equals(original, redacted)) {
+                valueBuilder.setStringValue(redacted);
+                return true;
+            }
+        }
+        if (valueBuilder.getValueCase() == AnyValue.ValueCase.KVLIST_VALUE) {
+            boolean changed = false;
+            for (KeyValue.Builder nested : valueBuilder.getKvlistValueBuilder().getValuesBuilderList()) {
+                if (redactionService.isSensitiveKey(nested.getKey())) {
+                    nested.setValue(redactedAnyValue());
+                    changed = true;
+                    continue;
+                }
+                changed |= redactAnyValue(nested.getValueBuilder());
+            }
+            return changed;
+        }
+        if (valueBuilder.getValueCase() == AnyValue.ValueCase.ARRAY_VALUE) {
+            boolean changed = false;
+            for (AnyValue.Builder item : valueBuilder.getArrayValueBuilder().getValuesBuilderList()) {
+                changed |= redactAnyValue(item);
+            }
+            return changed;
+        }
+        return false;
+    }
+
+    private AnyValue redactedAnyValue() {
+        return AnyValue.newBuilder().setStringValue(OtlpIngestionRedactionService.REDACTED).build();
+    }
+
     private byte[] maybeDecompress(byte[] content, HttpHeaders headers) {
         if (content == null || content.length == 0 || headers == null) {
             return content;
         }
-        String contentEncoding = headers.getFirst(HttpHeaders.CONTENT_ENCODING);
-        if (!StringUtils.equalsIgnoreCase(contentEncoding, CONTENT_ENCODING_GZIP)) {
+        if (!isGzipEncoded(headers)) {
             return content;
         }
         try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content);
@@ -222,6 +403,28 @@ public class GreptimeOtlpForwarder {
         }
     }
 
+    private boolean isGzipContentEncoding(String contentEncoding) {
+        String[] encodings = StringUtils.split(contentEncoding, ',');
+        if (encodings == null || encodings.length == 0) {
+            return false;
+        }
+        for (String encoding : encodings) {
+            if (StringUtils.equalsIgnoreCase(StringUtils.trimToEmpty(encoding), CONTENT_ENCODING_GZIP)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean isGzipEncoded(HttpHeaders headers) {
+        List<String> contentEncodings = headers == null ? null : headers.get(HttpHeaders.CONTENT_ENCODING);
+        return contentEncodings != null && contentEncodings.stream().anyMatch(this::isGzipContentEncoding);
+    }
+
+    private byte[] safeContent(byte[] content) {
+        return content == null ? new byte[0] : content;
+    }
+
     private String normalizeOtlpJson(String content) throws InvalidProtocolBufferException {
         try {
             JsonNode root = OBJECT_MAPPER.readTree(content);
@@ -273,6 +476,10 @@ public class GreptimeOtlpForwarder {
     }
 
     private String endpoint(String baseEndpoint, String path) {
-        return StringUtils.removeEnd(baseEndpoint, "/") + path;
+        return StringUtils.stripEnd(StringUtils.trim(baseEndpoint), "/") + path;
+    }
+
+    private String database(String configuredDatabase) {
+        return StringUtils.defaultIfBlank(StringUtils.trim(configuredDatabase), DEFAULT_GREPTIME_DB_NAME);
     }
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeTraceTableInitializer.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeTraceTableInitializer.java
new file mode 100644
index 0000000000..61104c98fc
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/forwarder/GreptimeTraceTableInitializer.java
@@ -0,0 +1,153 @@
+/*
+ * 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.hertzbeat.observability.ingestion.forwarder;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.retry.OtlpIngestionRetryService;
+import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * Creates the optimized Greptime native trace table before APM Flow startup.
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true")
+public class GreptimeTraceTableInitializer {
+
+    public static final String TRACE_TABLE_RESOURCE = "greptime/tables/hzb_traces.sql";
+
+    private static final String SQL_PATH = "/v1/sql";
+    private static final String DEFAULT_GREPTIME_DB_NAME = "public";
+
+    private final RestTemplate restTemplate;
+    private final ObjectProvider<GreptimeProperties> greptimePropertiesProvider;
+    private final OtlpIngestionRetryService retryService;
+
+    GreptimeTraceTableInitializer(RestTemplate restTemplate,
+                                  ObjectProvider<GreptimeProperties> greptimePropertiesProvider) {
+        this(restTemplate, greptimePropertiesProvider, new OtlpIngestionRetryService());
+    }
+
+    @Autowired
+    public GreptimeTraceTableInitializer(RestTemplate restTemplate,
+                                         ObjectProvider<GreptimeProperties> greptimePropertiesProvider,
+                                         OtlpIngestionRetryService retryService) {
+        this.restTemplate = restTemplate;
+        this.greptimePropertiesProvider = greptimePropertiesProvider;
+        this.retryService = retryService == null ? new OtlpIngestionRetryService() : retryService;
+    }
+
+    @Order(Ordered.HIGHEST_PRECEDENCE)
+    @EventListener(ApplicationReadyEvent.class)
+    public void initialize() {
+        GreptimeProperties greptimeProperties = greptimePropertiesOrNull();
+        if (greptimeProperties == null || !greptimeProperties.enabled()
+                || StringUtils.isBlank(greptimeProperties.httpEndpoint())) {
+            log.debug("[observability greptime-trace-table] skip initialization because Greptime is disabled.");
+            return;
+        }
+        try {
+            executeStatement(greptimeProperties, readTraceTableStatement());
+            log.info("[observability greptime-trace-table] initialized Greptime native trace table.");
+        } catch (IOException | RuntimeException ex) {
+            log.warn("[observability greptime-trace-table] failed to initialize Greptime native trace table: {}",
+                    ex.getMessage(), ex);
+        }
+    }
+
+    private GreptimeProperties greptimePropertiesOrNull() {
+        try {
+            return greptimePropertiesProvider.getIfAvailable();
+        } catch (RuntimeException ex) {
+            log.warn("[observability greptime-trace-table] failed to resolve Greptime properties: {}",
+                    ex.getMessage(), ex);
+            return null;
+        }
+    }
+
+    private void executeStatement(GreptimeProperties greptimeProperties, String statement) {
+        ResponseEntity<String> response = retryService.execute(() -> restTemplate.exchange(
+                        endpoint(greptimeProperties),
+                        HttpMethod.POST,
+                        sqlRequest(greptimeProperties, statement),
+                        String.class
+                ),
+                responseEntity -> responseEntity == null
+                        || retryService.isRetryableStatus(responseEntity.getStatusCode()));
+        if (response == null) {
+            log.warn("[observability greptime-trace-table] Greptime SQL statement returned no response.");
+            return;
+        }
+        if (!response.getStatusCode().is2xxSuccessful()) {
+            log.warn("[observability greptime-trace-table] Greptime SQL statement returned status {}.",
+                    response.getStatusCode());
+        }
+    }
+
+    private HttpEntity<String> sqlRequest(GreptimeProperties greptimeProperties, String sql) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.setAccept(List.of(MediaType.APPLICATION_JSON));
+        addAuthenticationHeader(headers, greptimeProperties);
+        return new HttpEntity<>("sql=" + UriUtils.encodeQueryParam(sql, StandardCharsets.UTF_8), headers);
+    }
+
+    private String readTraceTableStatement() throws IOException {
+        ClassPathResource resource = new ClassPathResource(TRACE_TABLE_RESOURCE);
+        return StringUtils.removeEnd(resource.getContentAsString(StandardCharsets.UTF_8).strip(), ";").strip();
+    }
+
+    private void addAuthenticationHeader(HttpHeaders headers, GreptimeProperties greptimeProperties) {
+        String username = StringUtils.trimToNull(greptimeProperties.username());
+        String password = StringUtils.trimToNull(greptimeProperties.password());
+        if (username == null || password == null) {
+            return;
+        }
+        headers.setBasicAuth(username, password, StandardCharsets.UTF_8);
+    }
+
+    private String endpoint(GreptimeProperties greptimeProperties) {
+        String endpoint = StringUtils.stripEnd(StringUtils.trim(greptimeProperties.httpEndpoint()), "/") + SQL_PATH;
+        return endpoint + "?db=" + UriUtils.encodeQueryParam(database(greptimeProperties.database()),
+                StandardCharsets.UTF_8);
+    }
+
+    private String database(String configuredDatabase) {
+        return StringUtils.defaultIfBlank(StringUtils.trim(configuredDatabase), DEFAULT_GREPTIME_DB_NAME);
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/governance/OtlpIngestionGovernanceService.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/governance/OtlpIngestionGovernanceService.java
new file mode 100644
index 0000000000..41cdb396d9
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/governance/OtlpIngestionGovernanceService.java
@@ -0,0 +1,150 @@
+/*
+ * 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.hertzbeat.observability.ingestion.governance;
+
+import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
+import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest;
+import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
+import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.logs.v1.ResourceLogs;
+import io.opentelemetry.proto.metrics.v1.ResourceMetrics;
+import io.opentelemetry.proto.resource.v1.Resource;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.observability.ingestion.semantic.OtlpResourceSemanticAttributes;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+/**
+ * Shared OTLP ingest governance policy boundary.
+ */
+@Service
+public class OtlpIngestionGovernanceService {
+
+    private static final String SERVICE_NAME = OtlpResourceSemanticAttributes.SERVICE_NAME;
+
+    private final Set<String> droppedServiceNames;
+
+    @Autowired
+    public OtlpIngestionGovernanceService(
+            @Value("${hertzbeat.otlp.ingestion.drop-service-names:}") String dropServiceNames) {
+        this.droppedServiceNames = parseValues(dropServiceNames);
+    }
+
+    public Decision evaluateMetrics(String protocol, ExportMetricsServiceRequest request) {
+        if (request == null) {
+            return Decision.allow();
+        }
+        for (ResourceMetrics resourceMetrics : request.getResourceMetricsList()) {
+            Decision decision = evaluateServiceName("metrics", protocol, resourceMetrics.getResource());
+            if (decision.dropped()) {
+                return decision;
+            }
+        }
+        return Decision.allow();
+    }
+
+    public Decision evaluateLogs(String protocol, ExportLogsServiceRequest request) {
+        if (request == null) {
+            return Decision.allow();
+        }
+        for (ResourceLogs resourceLogs : request.getResourceLogsList()) {
+            Decision decision = evaluateServiceName("logs", protocol, resourceLogs.getResource());
+            if (decision.dropped()) {
+                return decision;
+            }
+        }
+        return Decision.allow();
+    }
+
+    public Decision evaluateTraces(String protocol, ExportTraceServiceRequest request) {
+        if (request == null) {
+            return Decision.allow();
+        }
+        for (ResourceSpans resourceSpans : request.getResourceSpansList()) {
+            Decision decision = evaluateServiceName("traces", protocol, resourceSpans.getResource());
+            if (decision.dropped()) {
+                return decision;
+            }
+        }
+        return Decision.allow();
+    }
+
+    private Decision evaluateServiceName(String signal, String protocol, Resource resource) {
+        String serviceName = serviceName(resource);
+        if (!droppedServiceNames.contains(serviceName)) {
+            return Decision.allow();
+        }
+        return Decision.drop("OTLP " + dimension(signal) + " " + dimension(protocol)
+                + " dropped by governance policy: service.name=" + serviceName);
+    }
+
+    private String serviceName(Resource resource) {
+        if (resource == null) {
+            return null;
+        }
+        for (KeyValue attribute : resource.getAttributesList()) {
+            if (SERVICE_NAME.equals(attribute.getKey())) {
+                return stringValue(attribute.getValue());
+            }
+        }
+        return null;
+    }
+
+    private String stringValue(AnyValue value) {
+        if (value == null || !value.hasStringValue()) {
+            return null;
+        }
+        return StringUtils.trimToNull(value.getStringValue());
+    }
+
+    private Set<String> parseValues(String values) {
+        Set<String> parsedValues = new LinkedHashSet<>();
+        if (StringUtils.isBlank(values)) {
+            return parsedValues;
+        }
+        Arrays.stream(values.split("[,;]"))
+                .map(StringUtils::trimToNull)
+                .filter(value -> value != null)
+                .forEach(parsedValues::add);
+        return parsedValues;
+    }
+
+    private String dimension(String value) {
+        return StringUtils.defaultIfBlank(StringUtils.trimToNull(value), "signal");
+    }
+
+    /**
+     * Governance policy decision for a decoded OTLP batch.
+     */
+    public record Decision(boolean dropped, String reason) {
+
+        public static Decision allow() {
+            return new Decision(false, null);
+        }
+
+        public static Decision drop(String reason) {
+            return new Decision(true, reason);
+        }
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/quota/OtlpIngestionQuotaService.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/quota/OtlpIngestionQuotaService.java
new file mode 100644
index 0000000000..fa44ace57e
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/quota/OtlpIngestionQuotaService.java
@@ -0,0 +1,271 @@
+/*
+ * 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.hertzbeat.observability.ingestion.quota;
+
+import io.grpc.Status;
+import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
+import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest;
+import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
+import io.opentelemetry.proto.logs.v1.ResourceLogs;
+import io.opentelemetry.proto.logs.v1.ScopeLogs;
+import io.opentelemetry.proto.metrics.v1.ExponentialHistogramDataPoint;
+import io.opentelemetry.proto.metrics.v1.HistogramDataPoint;
+import io.opentelemetry.proto.metrics.v1.Metric;
+import io.opentelemetry.proto.metrics.v1.NumberDataPoint;
+import io.opentelemetry.proto.metrics.v1.ResourceMetrics;
+import io.opentelemetry.proto.metrics.v1.ScopeMetrics;
+import io.opentelemetry.proto.trace.v1.ResourceSpans;
+import io.opentelemetry.proto.trace.v1.ScopeSpans;
+import java.lang.management.ManagementFactory;
+import java.lang.management.MemoryUsage;
+import java.util.List;
+import java.util.function.DoubleSupplier;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+/**
+ * Shared OTLP ingest quota and backpressure guard.
+ */
+@Service
+public class OtlpIngestionQuotaService {
+
+    private final long maxRequestBytes;
+
+    private final long maxSignalItems;
+
+    private final double maxHeapUsageRatio;
+
+    private final DoubleSupplier heapUsageRatioSupplier;
+
+    @Autowired
+    public OtlpIngestionQuotaService(
+            @Value("${hertzbeat.otlp.ingestion.max-request-bytes:10485760}") long maxRequestBytes,
+            @Value("${hertzbeat.otlp.ingestion.max-signal-items:100000}") long maxSignalItems,
+            @Value("${hertzbeat.otlp.ingestion.max-heap-usage-ratio:0.95}") double maxHeapUsageRatio) {
+        this(maxRequestBytes, maxSignalItems, maxHeapUsageRatio,
+                OtlpIngestionQuotaService::currentJvmHeapUsageRatio);
+    }
+
+    public OtlpIngestionQuotaService(long maxRequestBytes) {
+        this(maxRequestBytes, Long.MAX_VALUE);
+    }
+
+    public OtlpIngestionQuotaService(long maxRequestBytes, long maxSignalItems) {
+        this(maxRequestBytes, maxSignalItems, 0D, () -> 0D);
+    }
+
+    public OtlpIngestionQuotaService(long maxRequestBytes, long maxSignalItems, double maxHeapUsageRatio,
+                                     DoubleSupplier heapUsageRatioSupplier) {
+        this.maxRequestBytes = maxRequestBytes;
+        this.maxSignalItems = maxSignalItems;
+        this.maxHeapUsageRatio = maxHeapUsageRatio;
+        this.heapUsageRatioSupplier = heapUsageRatioSupplier;
+    }
+
+    public void checkRequestBytes(String signal, String protocol, long requestBytes) {
+        if (maxRequestBytes > 0 && Math.max(requestBytes, 0L) > maxRequestBytes) {
+            throw Status.RESOURCE_EXHAUSTED
+                    .withDescription("OTLP " + dimension(signal) + " " + dimension(protocol)
+                            + " payload exceeds " + maxRequestBytes + " bytes.")
+                    .asRuntimeException();
+        }
+        checkMemoryPressure(signal, protocol);
+    }
+
+    public void checkMetricItems(String protocol, ExportMetricsServiceRequest request) {
+        checkSignalItems("metrics", protocol, countMetricItems(request));
+    }
+
+    public void checkMetricItems(String protocol, long signalItems) {
+        checkSignalItems("metrics", protocol, signalItems);
+    }
+
+    public void checkTraceItems(String protocol, ExportTraceServiceRequest request) {
+        checkSignalItems("traces", protocol, countTraceItems(request));
+    }
+
+    public void checkTraceItems(String protocol, long signalItems) {
+        checkSignalItems("traces", protocol, signalItems);
+    }
+
+    public void checkLogItems(String protocol, ExportLogsServiceRequest request) {
+        checkSignalItems("logs", protocol, countLogItems(request));
+    }
+
+    public void checkLogItems(String protocol, long signalItems) {
+        checkSignalItems("logs", protocol, signalItems);
+    }
+
+    private void checkSignalItems(String signal, String protocol, long signalItems) {
+        if (maxSignalItems <= 0 || Math.max(signalItems, 0L) <= maxSignalItems) {
+            checkMemoryPressure(signal, protocol);
+            return;
+        }
+        throw Status.RESOURCE_EXHAUSTED
+                .withDescription("OTLP " + dimension(signal) + " " + dimension(protocol)
+                        + " batch exceeds " + maxSignalItems + " signal items.")
+                .asRuntimeException();
+    }
+
+    private void checkMemoryPressure(String signal, String protocol) {
+        if (!isValidHeapPressureLimit()) {
+            return;
+        }
+        double heapUsageRatio = currentHeapUsageRatio();
+        if (heapUsageRatio < maxHeapUsageRatio) {
+            return;
+        }
+        throw Status.RESOURCE_EXHAUSTED
+                .withDescription("OTLP " + dimension(signal) + " " + dimension(protocol)
+                        + " ingestion paused because heap usage is " + percent(heapUsageRatio)
+                        + "% (limit " + percent(maxHeapUsageRatio) + "%).")
+                .asRuntimeException();
+    }
+
+    private boolean isValidHeapPressureLimit() {
+        return !Double.isNaN(maxHeapUsageRatio)
+                && !Double.isInfinite(maxHeapUsageRatio)
+                && maxHeapUsageRatio > 0D
+                && maxHeapUsageRatio <= 1D;
+    }
+
+    public long countMetricItems(ExportMetricsServiceRequest request) {
+        if (request == null) {
+            return 0L;
+        }
+        long count = 0L;
+        for (ResourceMetrics resourceMetrics : request.getResourceMetricsList()) {
+            for (ScopeMetrics scopeMetrics : resourceMetrics.getScopeMetricsList()) {
+                for (Metric metric : scopeMetrics.getMetricsList()) {
+                    count += countMetricDataPoints(metric);
+                }
+            }
+        }
+        return count;
+    }
+
+    private long countMetricDataPoints(Metric metric) {
+        if (metric == null) {
+            return 0L;
+        }
+        if (metric.hasGauge()) {
+            return countNumberDataPoints(metric.getGauge().getDataPointsList());
+        }
+        if (metric.hasSum()) {
+            return countNumberDataPoints(metric.getSum().getDataPointsList());
+        }
+        if (metric.hasHistogram()) {
+            return countHistogramDataPoints(metric.getHistogram().getDataPointsList());
+        }
+        if (metric.hasExponentialHistogram()) {
+            return countExponentialHistogramDataPoints(metric.getExponentialHistogram()
+                    .getDataPointsList());
+        }
+        if (metric.hasSummary()) {
+            return metric.getSummary().getDataPointsCount();
+        }
+        return 1L;
+    }
+
+    private long countNumberDataPoints(List<NumberDataPoint> dataPoints) {
+        long count = 0L;
+        for (NumberDataPoint dataPoint : dataPoints) {
+            count += 1L + dataPoint.getExemplarsCount();
+        }
+        return count;
+    }
+
+    private long countHistogramDataPoints(List<HistogramDataPoint> dataPoints) {
+        long count = 0L;
+        for (HistogramDataPoint dataPoint : dataPoints) {
+            count += 1L + dataPoint.getExemplarsCount();
+        }
+        return count;
+    }
+
+    private long countExponentialHistogramDataPoints(List<ExponentialHistogramDataPoint> dataPoints) {
+        long count = 0L;
+        for (ExponentialHistogramDataPoint dataPoint : dataPoints) {
+            count += 1L + dataPoint.getExemplarsCount();
+        }
+        return count;
+    }
+
+    public long countTraceItems(ExportTraceServiceRequest request) {
+        if (request == null) {
+            return 0L;
+        }
+        long count = 0L;
+        for (ResourceSpans resourceSpans : request.getResourceSpansList()) {
+            for (ScopeSpans scopeSpans : resourceSpans.getScopeSpansList()) {
+                for (var span : scopeSpans.getSpansList()) {
+                    count += 1L + span.getEventsCount() + span.getLinksCount();
+                }
+            }
+        }
+        return count;
+    }
+
+    public long countLogItems(ExportLogsServiceRequest request) {
+        if (request == null) {
+            return 0L;
+        }
+        long count = 0L;
+        for (ResourceLogs resourceLogs : request.getResourceLogsList()) {
+            for (ScopeLogs scopeLogs : resourceLogs.getScopeLogsList()) {
+                count += scopeLogs.getLogRecordsCount();
+            }
+        }
+        return count;
+    }
+
+    private String dimension(String value) {
+        return StringUtils.defaultIfBlank(StringUtils.trimToNull(value), "signal");
+    }
+
+    private double currentHeapUsageRatio() {
+        if (heapUsageRatioSupplier == null) {
+            return 0D;
+        }
+        try {
+            double heapUsageRatio = heapUsageRatioSupplier.getAsDouble();
+            if (Double.isNaN(heapUsageRatio) || Double.isInfinite(heapUsageRatio) || heapUsageRatio < 0D) {
+                return 0D;
+            }
+            return Math.min(heapUsageRatio, 1D);
+        } catch (RuntimeException ex) {
+            return 0D;
+        }
+    }
+
+    private long percent(double ratio) {
+        return Math.round(Math.max(0D, ratio) * 100D);
+    }
+
+    private static double currentJvmHeapUsageRatio() {
+        MemoryUsage heapMemoryUsage = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
+        long max = heapMemoryUsage.getMax();
+        long used = heapMemoryUsage.getUsed();
+        if (max <= 0L || used <= 0L) {
+            return 0D;
+        }
+        return (double) used / max;
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/red/OtlpIngestionRedSummaryService.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/red/OtlpIngestionRedSummaryService.java
new file mode 100644
index 0000000000..fcd340e6a4
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/red/OtlpIngestionRedSummaryService.java
@@ -0,0 +1,478 @@
+/*
+ * 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.hertzbeat.observability.ingestion.red;
+
+import io.grpc.Status;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionRedSummaryDto;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.observability.ingestion.audit.OtlpIngestionAuditEvent;
+import org.apache.hertzbeat.observability.ingestion.audit.OtlpIngestionAuditEventReader;
+import org.apache.hertzbeat.observability.ingestion.audit.OtlpIngestionAuditService;
+import org.apache.hertzbeat.observability.ingestion.redaction.OtlpIngestionRedactionService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * Builds recent OTLP ingest RED summaries from real audit events.
+ */
+@Service
+public class OtlpIngestionRedSummaryService {
+
+    private static final String OUTCOME_REJECTED = "rejected";
+    private static final String STATUS_OK = "OK";
+    private static final String UNKNOWN_DIMENSION = "unknown";
+    private static final int MAX_RECENT_EVENTS = 256;
+    private static final long MIN_RATE_WINDOW_MILLIS = 60_000L;
+    private static final List<String> SIGNAL_ORDER = List.of("metrics", "logs", "traces");
+    private static final List<String> PROTOCOL_ORDER = List.of("http", "grpc");
+    private static final Set<String> SUPPORTED_SIGNALS = Set.copyOf(SIGNAL_ORDER);
+    private static final Set<String> SUPPORTED_PROTOCOLS = Set.copyOf(PROTOCOL_ORDER);
+    private static final Set<String> SUPPORTED_OUTCOMES = Set.of("accepted", OUTCOME_REJECTED, "dropped");
+    private static final Set<String> SUPPORTED_STATUS_CODES = Set.of(
+            "OK",
+            "CANCELLED",
+            "UNKNOWN",
+            "INVALID_ARGUMENT",
+            "DEADLINE_EXCEEDED",
+            "NOT_FOUND",
+            "ALREADY_EXISTS",
+            "PERMISSION_DENIED",
+            "RESOURCE_EXHAUSTED",
+            "FAILED_PRECONDITION",
+            "ABORTED",
+            "OUT_OF_RANGE",
+            "UNIMPLEMENTED",
+            "INTERNAL",
+            "UNAVAILABLE",
+            "DATA_LOSS",
+            "UNAUTHENTICATED"
+    );
+
+    private final OtlpIngestionAuditService auditService;
+    private final List<OtlpIngestionAuditEventReader> eventReaders;
+    private final OtlpIngestionRedactionService redactionService;
+
+    public OtlpIngestionRedSummaryService(OtlpIngestionAuditService auditService) {
+        this(auditService, List.of(), new OtlpIngestionRedactionService());
+    }
+
+    public OtlpIngestionRedSummaryService(OtlpIngestionAuditService auditService,
+                                          List<OtlpIngestionAuditEventReader> eventReaders) {
+        this(auditService, eventReaders, new OtlpIngestionRedactionService());
+    }
+
+    @Autowired
+    public OtlpIngestionRedSummaryService(OtlpIngestionAuditService auditService,
+                                          List<OtlpIngestionAuditEventReader> eventReaders,
+                                          OtlpIngestionRedactionService redactionService) {
+        this.auditService = auditService;
+        this.eventReaders = eventReaders == null ? List.of() : List.copyOf(eventReaders.stream()
+                .filter(Objects::nonNull)
+                .toList());
+        this.redactionService = redactionService == null ? new OtlpIngestionRedactionService() : redactionService;
+    }
+
+    public OtlpIngestionRedSummaryDto getSummary() {
+        return getSummary(null);
+    }
+
+    public OtlpIngestionRedSummaryDto getSummary(Long startMillis) {
+        return getSummary(startMillis, null);
+    }
+
+    public OtlpIngestionRedSummaryDto getSummary(Long startMillis, Long endMillis) {
+        String workspaceId = StringUtils.trimToNull(AuthTokenRequestContext.currentWorkspaceId());
+        Long safeStartMillis = normalizeTimeMillis(startMillis);
+        Long safeEndMillis = normalizeTimeMillis(endMillis);
+        List<OtlpIngestionAuditEvent> events = eventsForWorkspace(workspaceId, safeStartMillis, safeEndMillis);
+        List<OtlpIngestionRedSummaryDto.SignalRedMetric> signals =
+                buildSignalSummaries(events, safeStartMillis, safeEndMillis);
+        return new OtlpIngestionRedSummaryDto(
+                workspaceId,
+                countRequests(events),
+                requestRatePerMinute(events, safeStartMillis, safeEndMillis),
+                countErrors(events),
+                errorRate(events),
+                requestBytes(events),
+                signalItems(events),
+                averageSignalItems(events),
+                maxSignalItems(events),
+                averageDurationMillis(events),
+                maxDurationMillis(events),
+                latestObservedAt(events),
+                signals
+        );
+    }
+
+    private List<OtlpIngestionAuditEvent> eventsForWorkspace(String workspaceId, Long startMillis, Long endMillis) {
+        boolean sawDurableRows = false;
+        for (OtlpIngestionAuditEventReader eventReader : eventReaders) {
+            try {
+                List<OtlpIngestionAuditEvent> rawEvents = eventReader
+                        .recentEvents(workspaceId, MAX_RECENT_EVENTS, startMillis, endMillis).stream()
+                        .filter(event -> event != null)
+                        .toList();
+                List<OtlpIngestionAuditEvent> scopedEvents = rawEvents.stream()
+                        .filter(event -> isInRequestedScope(event, workspaceId, startMillis, endMillis))
+                        .sorted(newestObservedAtFirst())
+                        .limit(MAX_RECENT_EVENTS)
+                        .toList();
+                sawDurableRows = sawDurableRows || !scopedEvents.isEmpty();
+                List<OtlpIngestionAuditEvent> events = scopedEvents.stream()
+                        .map(this::normalizeAuditEvent)
+                        .filter(this::hasPositiveObservedAt)
+                        .filter(this::hasSupportedAuditClassification)
+                        .toList();
+                if (!events.isEmpty()) {
+                    return events;
+                }
+            } catch (RuntimeException ignored) {
+                // RED summary should fall back to the in-memory audit window when durable readback is unavailable.
+            }
+        }
+        if (sawDurableRows) {
+            return List.of();
+        }
+        return auditService.recentEvents().stream()
+                .map(this::normalizeAuditEvent)
+                .filter(this::hasPositiveObservedAt)
+                .filter(this::hasSupportedAuditClassification)
+                .filter(event -> isInRequestedScope(event, workspaceId, startMillis, endMillis))
+                .sorted(newestObservedAtFirst())
+                .limit(MAX_RECENT_EVENTS)
+                .toList();
+    }
+
+    private Comparator<OtlpIngestionAuditEvent> newestObservedAtFirst() {
+        return Comparator.comparingLong(OtlpIngestionAuditEvent::observedAt).reversed();
+    }
+
+    private boolean isInRequestedScope(OtlpIngestionAuditEvent event, String workspaceId, Long startMillis,
+                                       Long endMillis) {
+        return (workspaceId == null || workspaceId.equals(StringUtils.trimToNull(event.workspaceId())))
+                && (startMillis == null || event.observedAt() >= startMillis)
+                && (endMillis == null || event.observedAt() <= endMillis);
+    }
+
+    private boolean hasPositiveObservedAt(OtlpIngestionAuditEvent event) {
+        return event.observedAt() > 0;
+    }
+
+    private boolean hasSupportedAuditClassification(OtlpIngestionAuditEvent event) {
+        String signal = normalizedOptionalDimension(event.signal());
+        String protocol = normalizedOptionalDimension(event.protocol());
+        String outcome = normalizedOptionalDimension(event.outcome());
+        String statusCode = normalizedStatusCode(event.statusCode());
+        return signal != null && protocol != null
+                && SUPPORTED_SIGNALS.contains(signal)
+                && SUPPORTED_PROTOCOLS.contains(protocol)
+                && statusCode != null
+                && (isSupportedOutcome(outcome) || isNonOkStatus(statusCode));
+    }
+
+    private boolean isSupportedOutcome(String outcome) {
+        return outcome != null && SUPPORTED_OUTCOMES.contains(outcome);
+    }
+
+    private boolean isNonOkStatus(String statusCode) {
+        return !StringUtils.equalsIgnoreCase(STATUS_OK, statusCode);
+    }
+
+    private Long normalizeTimeMillis(Long timeMillis) {
+        return timeMillis == null || timeMillis <= 0 ? null : timeMillis;
+    }
+
+    private String normalizeDimension(String value) {
+        String dimension = StringUtils.defaultIfBlank(StringUtils.trimToNull(value), UNKNOWN_DIMENSION);
+        return dimension.toLowerCase(Locale.ROOT);
+    }
+
+    private String normalizedOptionalDimension(String value) {
+        String dimension = StringUtils.trimToNull(value);
+        return dimension == null ? null : dimension.toLowerCase(Locale.ROOT);
+    }
+
+    private OtlpIngestionAuditEvent normalizeAuditEvent(OtlpIngestionAuditEvent event) {
+        return new OtlpIngestionAuditEvent(
+                normalizedOptionalDimension(event.signal()),
+                normalizedOptionalDimension(event.protocol()),
+                normalizedOptionalDimension(event.outcome()),
+                normalizedStatusCode(event.statusCode()),
+                StringUtils.trimToNull(event.workspaceId()),
+                StringUtils.trimToNull(event.entityId()),
+                StringUtils.trimToNull(event.ingestId()),
+                event.requestBytes(),
+                event.signalItems(),
+                event.reason(),
+                event.durationMillis(),
+                event.observedAt()
+        );
+    }
+
+    private String normalizedStatusCode(String value) {
+        String text = StringUtils.trimToNull(value);
+        if (text == null) {
+            return null;
+        }
+        String grpcStatusName = numericGrpcStatusName(text);
+        if (grpcStatusName != null) {
+            return grpcStatusName;
+        }
+        if (isNumericStatusCode(text)) {
+            return null;
+        }
+        String normalized = text.replaceAll("[\\s-]+", "_").replaceAll("^_+|_+$", "");
+        normalized = StringUtils.trimToNull(normalized) == null ? null : normalized.toUpperCase(Locale.ROOT);
+        return normalized != null && SUPPORTED_STATUS_CODES.contains(normalized) ? normalized : null;
+    }
+
+    private String numericGrpcStatusName(String text) {
+        String trimmed = StringUtils.trimToEmpty(text);
+        if (trimmed.isEmpty()) {
+            return null;
+        }
+        try {
+            BigDecimal numericStatus = new BigDecimal(trimmed).stripTrailingZeros();
+            if (numericStatus.scale() > 0) {
+                return null;
+            }
+            int codeValue = numericStatus.intValueExact();
+            if (codeValue < Status.Code.OK.value() || codeValue > Status.Code.UNAUTHENTICATED.value()) {
+                return null;
+            }
+            return Status.fromCodeValue(codeValue).getCode().name();
+        } catch (ArithmeticException | NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    private boolean isNumericStatusCode(String text) {
+        try {
+            new BigDecimal(text);
+            return true;
+        } catch (NumberFormatException ignored) {
+            return false;
+        }
+    }
+
+    private List<OtlpIngestionRedSummaryDto.SignalRedMetric> buildSignalSummaries(
+            List<OtlpIngestionAuditEvent> events, Long startMillis, Long endMillis) {
+        Map<SignalProtocol, List<OtlpIngestionAuditEvent>> groupedEvents = new LinkedHashMap<>();
+        for (OtlpIngestionAuditEvent event : events) {
+            groupedEvents.computeIfAbsent(
+                            new SignalProtocol(normalizeDimension(event.signal()), normalizeDimension(event.protocol())),
+                            ignored -> new ArrayList<>())
+                    .add(event);
+        }
+        return groupedEvents.entrySet().stream()
+                .sorted(Comparator.comparing((Map.Entry<SignalProtocol, List<OtlpIngestionAuditEvent>> entry) ->
+                                signalOrder(entry.getKey().signal()))
+                        .thenComparing(entry -> protocolOrder(entry.getKey().protocol()))
+                        .thenComparing(entry -> entry.getKey().signal())
+                        .thenComparing(entry -> entry.getKey().protocol()))
+                .map(entry -> toSignalSummary(entry.getKey(), entry.getValue(), startMillis, endMillis))
+                .toList();
+    }
+
+    private OtlpIngestionRedSummaryDto.SignalRedMetric toSignalSummary(
+            SignalProtocol signalProtocol, List<OtlpIngestionAuditEvent> events, Long startMillis, Long endMillis) {
+        OtlpIngestionAuditEvent latest = latestEvent(events);
+        return new OtlpIngestionRedSummaryDto.SignalRedMetric(
+                signalProtocol.signal(),
+                signalProtocol.protocol(),
+                countRequests(events),
+                requestRatePerMinute(events, startMillis, endMillis),
+                countErrors(events),
+                errorRate(events),
+                requestBytes(events),
+                signalItems(events),
+                averageSignalItems(events),
+                maxSignalItems(events),
+                averageDurationMillis(events),
+                maxDurationMillis(events),
+                latest == null ? null : latest.statusCode(),
+                latest == null ? null : redactionService.redactText(latest.reason()),
+                latest == null ? null : latest.observedAt()
+        );
+    }
+
+    private long countRequests(List<OtlpIngestionAuditEvent> events) {
+        return events.size();
+    }
+
+    private long countErrors(List<OtlpIngestionAuditEvent> events) {
+        return events.stream().filter(this::isErrorEvent).count();
+    }
+
+    private boolean isErrorEvent(OtlpIngestionAuditEvent event) {
+        String outcome = StringUtils.trimToNull(event.outcome());
+        if (StringUtils.equalsIgnoreCase(OUTCOME_REJECTED, outcome)) {
+            return true;
+        }
+        String statusCode = StringUtils.trimToNull(event.statusCode());
+        return statusCode != null && isNonOkStatus(statusCode);
+    }
+
+    private double requestRatePerMinute(List<OtlpIngestionAuditEvent> events, Long startMillis, Long endMillis) {
+        if (events.isEmpty()) {
+            return 0D;
+        }
+        long windowMillis = rateWindowMillis(events, startMillis, endMillis);
+        return (double) events.size() * MIN_RATE_WINDOW_MILLIS / windowMillis;
+    }
+
+    private long rateWindowMillis(List<OtlpIngestionAuditEvent> events, Long startMillis, Long endMillis) {
+        if (startMillis != null && endMillis != null && endMillis > startMillis) {
+            return endMillis - startMillis;
+        }
+        long firstObservedAt = events.stream()
+                .mapToLong(OtlpIngestionAuditEvent::observedAt)
+                .min()
+                .orElse(0L);
+        long latestObservedAt = events.stream()
+                .mapToLong(OtlpIngestionAuditEvent::observedAt)
+                .max()
+                .orElse(firstObservedAt);
+        return Math.max(MIN_RATE_WINDOW_MILLIS, latestObservedAt - firstObservedAt);
+    }
+
+    private double errorRate(List<OtlpIngestionAuditEvent> events) {
+        if (events.isEmpty()) {
+            return 0D;
+        }
+        return (double) countErrors(events) / events.size();
+    }
+
+    private long requestBytes(List<OtlpIngestionAuditEvent> events) {
+        long total = 0L;
+        for (OtlpIngestionAuditEvent event : events) {
+            total = saturatedAdd(total, nonNegativeCounter(event.requestBytes()));
+        }
+        return total;
+    }
+
+    private long signalItems(List<OtlpIngestionAuditEvent> events) {
+        long total = 0L;
+        for (OtlpIngestionAuditEvent event : events) {
+            Long signalItems = nonNegativeCounter(event.signalItems());
+            if (signalItems != null) {
+                total = saturatedAdd(total, signalItems);
+            }
+        }
+        return total;
+    }
+
+    private Long averageSignalItems(List<OtlpIngestionAuditEvent> events) {
+        List<Long> signalItems = events.stream()
+                .map(OtlpIngestionAuditEvent::signalItems)
+                .map(this::nonNegativeCounter)
+                .filter(value -> value != null)
+                .toList();
+        if (signalItems.isEmpty()) {
+            return null;
+        }
+        return averageCounter(signalItems);
+    }
+
+    private Long maxSignalItems(List<OtlpIngestionAuditEvent> events) {
+        return events.stream()
+                .map(OtlpIngestionAuditEvent::signalItems)
+                .map(this::nonNegativeCounter)
+                .filter(value -> value != null)
+                .max(Long::compareTo)
+                .orElse(null);
+    }
+
+    private Long averageDurationMillis(List<OtlpIngestionAuditEvent> events) {
+        List<Long> durations = events.stream()
+                .map(OtlpIngestionAuditEvent::durationMillis)
+                .map(this::nonNegativeCounter)
+                .filter(duration -> duration != null)
+                .toList();
+        if (durations.isEmpty()) {
+            return null;
+        }
+        return averageCounter(durations);
+    }
+
+    private Long maxDurationMillis(List<OtlpIngestionAuditEvent> events) {
+        return events.stream()
+                .map(OtlpIngestionAuditEvent::durationMillis)
+                .map(this::nonNegativeCounter)
+                .filter(duration -> duration != null)
+                .max(Long::compareTo)
+                .orElse(null);
+    }
+
+    private long nonNegativeCounter(long value) {
+        return Math.max(value, 0L);
+    }
+
+    private Long nonNegativeCounter(Long value) {
+        return value == null ? null : nonNegativeCounter(value.longValue());
+    }
+
+    private long saturatedAdd(long left, long right) {
+        return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right;
+    }
+
+    private long averageCounter(List<Long> values) {
+        double average = values.stream()
+                .mapToDouble(Long::doubleValue)
+                .average()
+                .orElse(0D);
+        if (average >= Long.MAX_VALUE) {
+            return Long.MAX_VALUE;
+        }
+        return Math.round(average);
+    }
+
+    private Long latestObservedAt(List<OtlpIngestionAuditEvent> events) {
+        OtlpIngestionAuditEvent latest = latestEvent(events);
+        return latest == null ? null : latest.observedAt();
+    }
+
+    private OtlpIngestionAuditEvent latestEvent(List<OtlpIngestionAuditEvent> events) {
+        return events.stream()
+                .max(Comparator.comparingLong(OtlpIngestionAuditEvent::observedAt))
+                .orElse(null);
+    }
+
+    private int signalOrder(String signal) {
+        int index = SIGNAL_ORDER.indexOf(signal);
+        return index < 0 ? SIGNAL_ORDER.size() : index;
+    }
+
+    private int protocolOrder(String protocol) {
+        int index = PROTOCOL_ORDER.indexOf(protocol);
+        return index < 0 ? PROTOCOL_ORDER.size() : index;
+    }
+
+    private record SignalProtocol(String signal, String protocol) {
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/redaction/OtlpIngestionRedactionService.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/redaction/OtlpIngestionRedactionService.java
new file mode 100644
index 0000000000..24ba4b2147
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/redaction/OtlpIngestionRedactionService.java
@@ -0,0 +1,160 @@
+/*
+ * 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.hertzbeat.observability.ingestion.redaction;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+/**
+ * Shared sensitive-data redaction boundary for OTLP ingest payloads and self-observability evidence.
+ */
+@Service
+public class OtlpIngestionRedactionService {
+
+    public static final String REDACTED = "[REDACTED]";
+
+    private static final Pattern INLINE_SECRET_PATTERN = Pattern.compile(
+            "(?i)\\b(password|passwd|pwd|token|access[._-]?token|refresh[._-]?token|api[._-]?key|"
+                    + "client[._-]?secret|private[._-]?key|secret|authorization|cookie)\\b([\"']?\\s*[:=]\\s*)"
+                    + "(Bearer\\s+[^\\s,;&]+|Basic\\s+[^\\s,;&]+|\"[^\"]*\"|'[^']*'|[^\\s,;&]+)"
+    );
+    private static final Pattern BEARER_TOKEN_PATTERN = Pattern.compile("(?i)\\bBearer\\s+[^\\s,;&]+");
+    private static final Pattern BASIC_TOKEN_PATTERN = Pattern.compile("(?i)\\bBasic\\s+[^\\s,;&]+");
+
+    public Map<String, Object> redactObjectMap(Map<String, Object> source) {
+        if (source == null || source.isEmpty()) {
+            return Map.of();
+        }
+        Map<String, Object> redacted = new LinkedHashMap<>();
+        source.forEach((key, value) -> {
+            if (StringUtils.isBlank(key)) {
+                return;
+            }
+            redacted.put(key, redactObject(key, value));
+        });
+        return redacted;
+    }
+
+    public Map<String, String> redactStringMap(Map<String, String> source) {
+        if (source == null || source.isEmpty()) {
+            return Map.of();
+        }
+        Map<String, String> redacted = new LinkedHashMap<>();
+        source.forEach((key, value) -> {
+            if (StringUtils.isBlank(key)) {
+                return;
+            }
+            if (isSensitiveKey(key)) {
+                redacted.put(key, REDACTED);
+            } else {
+                redacted.put(key, redactText(value));
+            }
+        });
+        return redacted;
+    }
+
+    public Object redactObject(String key, Object value) {
+        if (isSensitiveKey(key)) {
+            return REDACTED;
+        }
+        if (value instanceof CharSequence text) {
+            return redactText(text.toString());
+        }
+        if (value instanceof Map<?, ?> map) {
+            return redactGenericMap(map);
+        }
+        if (value instanceof Iterable<?> iterable) {
+            List<Object> redacted = new ArrayList<>();
+            iterable.forEach(item -> redacted.add(redactObject(null, item)));
+            return redacted;
+        }
+        return value;
+    }
+
+    public String redactText(String text) {
+        if (StringUtils.isBlank(text)) {
+            return text;
+        }
+        String redacted = redactInlineSecrets(text);
+        redacted = BEARER_TOKEN_PATTERN.matcher(redacted).replaceAll("Bearer " + REDACTED);
+        return BASIC_TOKEN_PATTERN.matcher(redacted).replaceAll("Basic " + REDACTED);
+    }
+
+    public boolean isSensitiveKey(String key) {
+        if (StringUtils.isBlank(key)) {
+            return false;
+        }
+        String normalized = key.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_");
+        return normalized.contains("password")
+                || normalized.contains("passwd")
+                || normalized.contains("secret")
+                || normalized.contains("token")
+                || normalized.contains("authorization")
+                || normalized.contains("cookie")
+                || normalized.contains("api_key")
+                || normalized.contains("apikey")
+                || normalized.contains("private_key")
+                || normalized.contains("client_secret")
+                || normalized.contains("access_token")
+                || normalized.contains("refresh_token");
+    }
+
+    private Map<String, Object> redactGenericMap(Map<?, ?> source) {
+        if (source == null || source.isEmpty()) {
+            return Map.of();
+        }
+        Map<String, Object> redacted = new LinkedHashMap<>();
+        source.forEach((key, value) -> {
+            if (key == null || StringUtils.isBlank(String.valueOf(key))) {
+                return;
+            }
+            String normalizedKey = String.valueOf(key);
+            redacted.put(normalizedKey, redactObject(normalizedKey, value));
+        });
+        return redacted;
+    }
+
+    private String redactInlineSecrets(String text) {
+        Matcher matcher = INLINE_SECRET_PATTERN.matcher(text);
+        StringBuffer redacted = new StringBuffer();
+        while (matcher.find()) {
+            String replacement = matcher.group(1) + matcher.group(2) + redactInlineSecretValue(matcher.group(3));
+            matcher.appendReplacement(redacted, Matcher.quoteReplacement(replacement));
+        }
+        matcher.appendTail(redacted);
+        return redacted.toString();
+    }
+
+    private String redactInlineSecretValue(String value) {
+        if (value != null && value.length() >= 2) {
+            char first = value.charAt(0);
+            char last = value.charAt(value.length() - 1);
+            if ((first == '"' || first == '\'') && first == last) {
+                return String.valueOf(first) + REDACTED + first;
+            }
+        }
+        return REDACTED;
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/retry/OtlpIngestionRetryService.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/retry/OtlpIngestionRetryService.java
new file mode 100644
index 0000000000..b3a6e9c837
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/retry/OtlpIngestionRetryService.java
@@ -0,0 +1,265 @@
+/*
+ * 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.hertzbeat.observability.ingestion.retry;
+
+import io.grpc.Metadata;
+import io.grpc.Status;
+import io.grpc.StatusException;
+import io.grpc.StatusRuntimeException;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.function.LongConsumer;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import org.apache.hertzbeat.observability.ingestion.error.OtlpIngestionBackpressureHeaders;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.ResourceAccessException;
+
+/**
+ * Shared retry boundary for transient OTLP backend writes.
+ */
+@Service
+public class OtlpIngestionRetryService {
+
+    private static final int DEFAULT_MAX_ATTEMPTS = 2;
+    private static final long DEFAULT_MAX_RETRY_AFTER_MILLIS = 60_000L;
+
+    private final int maxAttempts;
+    private final long backoffMillis;
+    private final long maxRetryAfterMillis;
+    private final LongConsumer backoffSleeper;
+
+    public OtlpIngestionRetryService() {
+        this(DEFAULT_MAX_ATTEMPTS, 0L, OtlpIngestionRetryService::sleep);
+    }
+
+    @Autowired
+    public OtlpIngestionRetryService(
+            @Value("${hertzbeat.otlp.ingestion.retry.max-attempts:2}") int maxAttempts,
+            @Value("${hertzbeat.otlp.ingestion.retry.backoff-millis:0}") long backoffMillis,
+            @Value("${hertzbeat.otlp.ingestion.retry.max-retry-after-millis:60000}") long maxRetryAfterMillis) {
+        this(maxAttempts, backoffMillis, maxRetryAfterMillis, OtlpIngestionRetryService::sleep);
+    }
+
+    public OtlpIngestionRetryService(int maxAttempts) {
+        this(maxAttempts, 0L, OtlpIngestionRetryService::sleep);
+    }
+
+    OtlpIngestionRetryService(int maxAttempts, long backoffMillis, LongConsumer backoffSleeper) {
+        this(maxAttempts, backoffMillis, DEFAULT_MAX_RETRY_AFTER_MILLIS, backoffSleeper);
+    }
+
+    OtlpIngestionRetryService(int maxAttempts, long backoffMillis, long maxRetryAfterMillis,
+                              LongConsumer backoffSleeper) {
+        this.maxAttempts = Math.max(1, maxAttempts);
+        this.backoffMillis = Math.max(0L, backoffMillis);
+        this.maxRetryAfterMillis = Math.max(0L, maxRetryAfterMillis);
+        this.backoffSleeper = backoffSleeper == null ? OtlpIngestionRetryService::sleep : backoffSleeper;
+    }
+
+    public <T> T execute(Supplier<T> operation) {
+        return execute(operation, result -> false);
+    }
+
+    public <T> T execute(Supplier<T> operation, Predicate<T> retryableResult) {
+        RuntimeException lastFailure = null;
+        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+            try {
+                T result = operation.get();
+                if (attempt < maxAttempts && retryableResult != null && retryableResult.test(result)) {
+                    waitBeforeRetry(result);
+                    continue;
+                }
+                return result;
+            } catch (RuntimeException ex) {
+                lastFailure = ex;
+                if (attempt >= maxAttempts || !isRetryable(ex)) {
+                    throw ex;
+                }
+                waitBeforeRetry(ex);
+            }
+        }
+        throw lastFailure;
+    }
+
+    private boolean isRetryable(RuntimeException exception) {
+        Throwable current = exception;
+        while (current != null) {
+            if (current instanceof ResourceAccessException) {
+                return true;
+            }
+            if (current instanceof HttpStatusCodeException statusException) {
+                return isRetryableStatus(statusException.getStatusCode());
+            }
+            if (current instanceof StatusRuntimeException statusException) {
+                return isRetryableGrpcStatus(statusException.getStatus().getCode());
+            }
+            if (current instanceof StatusException statusException) {
+                return isRetryableGrpcStatus(statusException.getStatus().getCode());
+            }
+            current = current.getCause();
+        }
+        return false;
+    }
+
+    public boolean isRetryableStatus(HttpStatusCode statusCode) {
+        if (statusCode == null) {
+            return false;
+        }
+        return switch (statusCode.value()) {
+            case 408, 425, 429, 502, 503, 504 -> true;
+            default -> false;
+        };
+    }
+
+    private boolean isRetryableGrpcStatus(Status.Code statusCode) {
+        return Status.Code.UNAVAILABLE == statusCode
+                || Status.Code.DEADLINE_EXCEEDED == statusCode
+                || Status.Code.RESOURCE_EXHAUSTED == statusCode;
+    }
+
+    private void waitBeforeRetry(Object retryableResult) {
+        waitBeforeRetry(retryAfterMillis(retryableResult));
+    }
+
+    private void waitBeforeRetry() {
+        waitBeforeRetry(null);
+    }
+
+    private void waitBeforeRetry(Long retryAfterMillis) {
+        long delayMillis = retryAfterMillis == null
+                ? backoffMillis
+                : Math.min(Math.max(0L, retryAfterMillis), maxRetryAfterMillis);
+        if (delayMillis > 0L) {
+            backoffSleeper.accept(delayMillis);
+        }
+    }
+
+    private Long retryAfterMillis(Object retryableResult) {
+        if (retryableResult instanceof ResponseEntity<?> responseEntity) {
+            return retryAfterMillis(responseEntity.getHeaders());
+        }
+        if (retryableResult instanceof Throwable throwable) {
+            return retryAfterMillis(throwable);
+        }
+        return null;
+    }
+
+    private Long retryAfterMillis(Throwable throwable) {
+        Throwable current = throwable;
+        while (current != null) {
+            if (current instanceof HttpStatusCodeException statusException) {
+                return isRetryableStatus(statusException.getStatusCode())
+                        ? retryAfterMillis(statusException.getResponseHeaders())
+                        : null;
+            }
+            if (current instanceof StatusRuntimeException statusException) {
+                return isRetryableGrpcStatus(statusException.getStatus().getCode())
+                        ? retryAfterMillis(statusException.getTrailers())
+                        : null;
+            }
+            if (current instanceof StatusException statusException) {
+                return isRetryableGrpcStatus(statusException.getStatus().getCode())
+                        ? retryAfterMillis(statusException.getTrailers())
+                        : null;
+            }
+            current = current.getCause();
+        }
+        return null;
+    }
+
+    private Long retryAfterMillis(Metadata trailers) {
+        if (trailers == null) {
+            return null;
+        }
+        return retryAfterHeaderMillis(trailers.get(OtlpIngestionBackpressureHeaders.RETRY_AFTER_TRAILER_KEY));
+    }
+
+    private Long retryAfterMillis(HttpHeaders headers) {
+        if (headers == null) {
+            return null;
+        }
+        List<String> values = headers.get(HttpHeaders.RETRY_AFTER);
+        if (values == null) {
+            return null;
+        }
+        for (String value : values) {
+            Long delayMillis = retryAfterHeaderMillis(value);
+            if (delayMillis != null) {
+                return delayMillis;
+            }
+        }
+        return null;
+    }
+
+    private Long retryAfterHeaderMillis(String value) {
+        Long delayMillis = retryAfterSecondsMillis(value);
+        if (delayMillis != null) {
+            return delayMillis;
+        }
+        return retryAfterDateMillis(value);
+    }
+
+    private Long retryAfterSecondsMillis(String value) {
+        String trimmed = value == null ? "" : value.trim();
+        if (!trimmed.matches("\\d+")) {
+            return null;
+        }
+        try {
+            long seconds = Long.parseLong(trimmed);
+            if (seconds > Long.MAX_VALUE / 1000L) {
+                return Long.MAX_VALUE;
+            }
+            return seconds * 1000L;
+        } catch (NumberFormatException ignored) {
+            return Long.MAX_VALUE;
+        }
+    }
+
+    private Long retryAfterDateMillis(String value) {
+        String trimmed = value == null ? "" : value.trim();
+        if (trimmed.isEmpty()) {
+            return null;
+        }
+        try {
+            Instant retryAt = ZonedDateTime.parse(trimmed, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
+            return Math.max(0L, Duration.between(Instant.now(), retryAt).toMillis());
+        } catch (DateTimeException | ArithmeticException ignored) {
+            return null;
+        }
+    }
+
+    private static void sleep(long millis) {
+        try {
+            Thread.sleep(millis);
+        } catch (InterruptedException ex) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("Interrupted while waiting to retry OTLP ingestion.", ex);
+        }
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/security/OtlpIngestionRequestContextResolver.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/security/OtlpIngestionRequestContextResolver.java
new file mode 100644
index 0000000000..7e03fcb293
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/security/OtlpIngestionRequestContextResolver.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.hertzbeat.observability.ingestion.security;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.observability.ingestion.enricher.OtlpCorrelationContext;
+import org.apache.hertzbeat.observability.ingestion.semantic.OtlpResourceSemanticAttributes;
+import org.springframework.stereotype.Component;
+
+/**
+ * Resolves authenticated OTLP ingest request context for signal enrichment and intake records.
+ */
+@Component
+public class OtlpIngestionRequestContextResolver {
+
+    public OtlpCorrelationContext currentCorrelationContext() {
+        String workspaceId = AuthTokenRequestContext.currentWorkspaceId();
+        if (StringUtils.isBlank(workspaceId)) {
+            return OtlpCorrelationContext.empty();
+        }
+        return new OtlpCorrelationContext(null, null, workspaceId);
+    }
+
+    public Map<String, String> withWorkspaceResourceAttributes(Map<String, String> resourceAttributes) {
+        Map<String, String> resolved = new LinkedHashMap<>();
+        if (resourceAttributes != null) {
+            resolved.putAll(resourceAttributes);
+        }
+        String workspaceId = AuthTokenRequestContext.currentWorkspaceId();
+        if (StringUtils.isNotBlank(workspaceId)) {
+            resolved.put(OtlpResourceSemanticAttributes.HERTZBEAT_WORKSPACE_ID, workspaceId);
+        }
+        return resolved;
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/semantic/OtlpResourceSemanticAttributes.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/semantic/OtlpResourceSemanticAttributes.java
new file mode 100644
index 0000000000..a4f9884f7c
--- /dev/null
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/semantic/OtlpResourceSemanticAttributes.java
@@ -0,0 +1,70 @@
+/*
+ * 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.hertzbeat.observability.ingestion.semantic;
+
+import java.util.List;
+
+/**
+ * OTLP resource keys HertzBeat treats as stable ingestion and entity-correlation semantics.
+ */
+public final class OtlpResourceSemanticAttributes {
+
+    public static final String SERVICE_NAME = "service.name";
+    public static final String SERVICE_NAMESPACE = "service.namespace";
+    public static final String DEPLOYMENT_ENVIRONMENT_NAME = "deployment.environment.name";
+    public static final String SERVICE_INSTANCE_ID = "service.instance.id";
+    public static final String HOST_NAME = "host.name";
+    public static final String HOST_ID = "host.id";
+    public static final String K8S_CLUSTER_NAME = "k8s.cluster.name";
+    public static final String K8S_NAMESPACE_NAME = "k8s.namespace.name";
+    public static final String K8S_NODE_NAME = "k8s.node.name";
+    public static final String K8S_POD_NAME = "k8s.pod.name";
+    public static final String K8S_CONTAINER_NAME = "k8s.container.name";
+    public static final String HERTZBEAT_ENTITY_ID = "hertzbeat.entity_id";
+    public static final String HERTZBEAT_WORKSPACE_ID = "hertzbeat.workspace_id";
+
+    public static final List<String> PRIMARY_OTEL_IDENTITY_KEYS = List.of(
+            SERVICE_NAME,
+            SERVICE_NAMESPACE,
+            DEPLOYMENT_ENVIRONMENT_NAME
+    );
+
+    public static final List<String> HERTZBEAT_CONTEXT_KEYS = List.of(
+            HERTZBEAT_ENTITY_ID,
+            HERTZBEAT_WORKSPACE_ID
+    );
+
+    public static final List<String> GREPTIME_METRIC_PROMOTED_RESOURCE_KEYS = List.of(
+            SERVICE_NAME,
+            SERVICE_NAMESPACE,
+            DEPLOYMENT_ENVIRONMENT_NAME,
+            SERVICE_INSTANCE_ID,
+            HOST_NAME,
+            HOST_ID,
+            K8S_CLUSTER_NAME,
+            K8S_NAMESPACE_NAME,
+            K8S_NODE_NAME,
+            K8S_POD_NAME,
+            K8S_CONTAINER_NAME,
+            HERTZBEAT_ENTITY_ID,
+            HERTZBEAT_WORKSPACE_ID
+    );
+
+    private OtlpResourceSemanticAttributes() {
+    }
+}
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpGrpcIngestionService.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpGrpcIngestionService.java
index c67f9e2c68..dce21a6f30 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpGrpcIngestionService.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpGrpcIngestionService.java
@@ -34,6 +34,8 @@ public interface OtlpGrpcIngestionService {
 
     ResponseEntity<byte[]> ingestMetricsHttp(byte[] content, HttpHeaders requestHeaders);
 
+    ResponseEntity<byte[]> ingestLogsHttp(byte[] content, HttpHeaders requestHeaders);
+
     ResponseEntity<byte[]> ingestTracesHttp(byte[] content, HttpHeaders requestHeaders);
 
     ExportMetricsServiceResponse ingestMetricsGrpc(ExportMetricsServiceRequest request);
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpGrpcIngestionServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpGrpcIngestionServiceImpl.java
index 00a5c8fcde..ae4b98867a 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpGrpcIngestionServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpGrpcIngestionServiceImpl.java
@@ -23,22 +23,33 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.google.protobuf.InvalidProtocolBufferException;
 import com.google.protobuf.Message;
 import com.google.protobuf.util.JsonFormat;
-import com.google.rpc.Status;
 import io.grpc.StatusRuntimeException;
 import io.opentelemetry.proto.common.v1.AnyValue;
+import io.opentelemetry.proto.common.v1.ArrayValue;
+import io.opentelemetry.proto.common.v1.InstrumentationScope;
 import io.opentelemetry.proto.common.v1.KeyValue;
+import io.opentelemetry.proto.common.v1.KeyValueList;
 import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
 import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceResponse;
 import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest;
 import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceResponse;
 import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
 import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse;
+import io.opentelemetry.proto.logs.v1.LogRecord;
+import io.opentelemetry.proto.logs.v1.ResourceLogs;
+import io.opentelemetry.proto.logs.v1.ScopeLogs;
+import io.opentelemetry.proto.metrics.v1.Exemplar;
+import io.opentelemetry.proto.metrics.v1.ExponentialHistogram;
 import io.opentelemetry.proto.metrics.v1.ExponentialHistogramDataPoint;
+import io.opentelemetry.proto.metrics.v1.Gauge;
+import io.opentelemetry.proto.metrics.v1.Histogram;
 import io.opentelemetry.proto.metrics.v1.HistogramDataPoint;
 import io.opentelemetry.proto.metrics.v1.Metric;
 import io.opentelemetry.proto.metrics.v1.NumberDataPoint;
 import io.opentelemetry.proto.metrics.v1.ResourceMetrics;
 import io.opentelemetry.proto.metrics.v1.ScopeMetrics;
+import io.opentelemetry.proto.metrics.v1.Sum;
+import io.opentelemetry.proto.metrics.v1.Summary;
 import io.opentelemetry.proto.metrics.v1.SummaryDataPoint;
 import io.opentelemetry.proto.trace.v1.ResourceSpans;
 import io.opentelemetry.proto.trace.v1.ScopeSpans;
@@ -54,25 +65,39 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import java.util.zip.GZIPInputStream;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hertzbeat.common.observability.gateway.ObservabilitySignalIntakeGateway;
 import org.apache.hertzbeat.observability.ingestion.service.OtlpGrpcIngestionService;
 import org.apache.hertzbeat.observability.ingestion.adapter.OtlpLogProtocolAdapter;
+import org.apache.hertzbeat.observability.ingestion.audit.OtlpIngestionAuditService;
 import org.apache.hertzbeat.observability.ingestion.enricher.OtlpCorrelationContext;
 import org.apache.hertzbeat.observability.ingestion.enricher.OtlpCorrelationEnricher;
+import org.apache.hertzbeat.observability.ingestion.enricher.OtlpEntityIdentityResolver;
+import org.apache.hertzbeat.observability.ingestion.error.OtlpIngestionBackpressureHeaders;
+import org.apache.hertzbeat.observability.ingestion.error.OtlpIngestionErrorResponseFactory;
 import org.apache.hertzbeat.observability.ingestion.forwarder.GreptimeOtlpForwarder;
+import org.apache.hertzbeat.observability.ingestion.governance.OtlpIngestionGovernanceService;
+import org.apache.hertzbeat.observability.ingestion.quota.OtlpIngestionQuotaService;
+import org.apache.hertzbeat.observability.ingestion.redaction.OtlpIngestionRedactionService;
+import org.apache.hertzbeat.observability.ingestion.retry.OtlpIngestionRetryService;
+import org.apache.hertzbeat.observability.ingestion.semantic.OtlpResourceSemanticAttributes;
+import org.apache.hertzbeat.observability.ingestion.security.OtlpIngestionRequestContextResolver;
 import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpStatusCodeException;
 import org.springframework.web.client.RestClientException;
 import org.springframework.web.client.RestTemplate;
 
@@ -84,8 +109,11 @@ import org.springframework.web.client.RestTemplate;
 public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
 
     private static final String CONTENT_TYPE_PROTOBUF = "application/x-protobuf";
+    private static final MediaType MEDIA_TYPE_PROTOBUF = MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF);
+    private static final MediaType MEDIA_TYPE_PROTOBUF_ALT = MediaType.parseMediaType("application/protobuf");
     private static final String CONTENT_ENCODING = "Content-Encoding";
     private static final String CONTENT_ENCODING_GZIP = "gzip";
+    private static final int GZIP_DECOMPRESSION_BUFFER_BYTES = 8192;
     private static final String GREPTIME_DB_NAME_HEADER = "X-Greptime-DB-Name";
     private static final String GREPTIME_OTLP_METRIC_PROMOTE_ALL_RESOURCE_ATTRS_HEADER =
             "X-Greptime-OTLP-Metric-Promote-All-Resource-Attrs";
@@ -98,20 +126,11 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
     private static final String DEFAULT_GREPTIME_DB_NAME = "public";
     private static final String DEFAULT_TRACES_TABLE_NAME = "hzb_traces";
     private static final String DEFAULT_TRACE_PIPELINE = "greptime_trace_v1";
-    private static final String DEFAULT_METRIC_PROMOTED_RESOURCE_ATTRS = String.join(";",
-            "service.name",
-            "service.namespace",
-            "deployment.environment.name",
-            "service.instance.id",
-            "host.name",
-            "host.id",
-            "k8s.cluster.name",
-            "k8s.namespace.name",
-            "k8s.node.name",
-            "k8s.pod.name",
-            "k8s.container.name"
-    );
+    private static final String DEFAULT_METRIC_PROMOTED_RESOURCE_ATTRS =
+            String.join(";", OtlpResourceSemanticAttributes.GREPTIME_METRIC_PROMOTED_RESOURCE_KEYS);
     private static final Set<String> OTLP_HEX_ID_FIELDS = Set.of("traceId", "spanId", "parentSpanId");
+    private static final int OTLP_TRACE_ID_BYTES = 16;
+    private static final int OTLP_SPAN_ID_BYTES = 8;
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
     private static final String OTLP_METRIC_COMPATIBILITY = "otlp.metric.compatibility";
     private static final String OTLP_METRIC_COMPATIBILITY_REASON = "otlp.metric.compatibility.reason";
@@ -153,74 +172,247 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
     private final OtlpLogProtocolAdapter otlpLogProtocolAdapter;
     private final GreptimeOtlpForwarder greptimeOtlpForwarder;
     private final OtlpCorrelationEnricher otlpCorrelationEnricher;
+    private final OtlpIngestionErrorResponseFactory errorResponseFactory;
+    private final OtlpIngestionRequestContextResolver requestContextResolver;
+    private final OtlpIngestionAuditService auditService;
+    private final OtlpIngestionGovernanceService governanceService;
+    private final OtlpIngestionQuotaService quotaService;
     private final ObservabilitySignalIntakeGateway observabilitySignalIntakeGateway;
+    private final OtlpEntityIdentityResolver otlpEntityIdentityResolver;
+    private final OtlpIngestionRedactionService redactionService = new OtlpIngestionRedactionService();
+    private final OtlpIngestionRetryService retryService;
 
     public OtlpGrpcIngestionServiceImpl(RestTemplate restTemplate,
                                         ObjectProvider<GreptimeProperties> greptimePropertiesProvider,
                                         OtlpLogProtocolAdapter otlpLogProtocolAdapter,
                                         GreptimeOtlpForwarder greptimeOtlpForwarder,
                                         OtlpCorrelationEnricher otlpCorrelationEnricher,
+                                        OtlpIngestionErrorResponseFactory errorResponseFactory,
+                                        OtlpIngestionRequestContextResolver requestContextResolver,
+                                        OtlpIngestionAuditService auditService,
+                                        OtlpIngestionGovernanceService governanceService,
+                                        OtlpIngestionQuotaService quotaService,
                                         @Qualifier("telemetryIntakeServiceImpl")
-                                        ObservabilitySignalIntakeGateway observabilitySignalIntakeGateway) {
+                                        ObservabilitySignalIntakeGateway observabilitySignalIntakeGateway,
+                                        OtlpEntityIdentityResolver otlpEntityIdentityResolver) {
+        this(restTemplate, greptimePropertiesProvider, otlpLogProtocolAdapter, greptimeOtlpForwarder,
+                otlpCorrelationEnricher, errorResponseFactory, requestContextResolver, auditService, governanceService,
+                quotaService, observabilitySignalIntakeGateway, otlpEntityIdentityResolver,
+                new OtlpIngestionRetryService());
+    }
+
+    @Autowired
+    public OtlpGrpcIngestionServiceImpl(RestTemplate restTemplate,
+                                        ObjectProvider<GreptimeProperties> greptimePropertiesProvider,
+                                        OtlpLogProtocolAdapter otlpLogProtocolAdapter,
+                                        GreptimeOtlpForwarder greptimeOtlpForwarder,
+                                        OtlpCorrelationEnricher otlpCorrelationEnricher,
+                                        OtlpIngestionErrorResponseFactory errorResponseFactory,
+                                        OtlpIngestionRequestContextResolver requestContextResolver,
+                                        OtlpIngestionAuditService auditService,
+                                        OtlpIngestionGovernanceService governanceService,
+                                        OtlpIngestionQuotaService quotaService,
+                                        @Qualifier("telemetryIntakeServiceImpl")
+                                        ObservabilitySignalIntakeGateway observabilitySignalIntakeGateway,
+                                        OtlpEntityIdentityResolver otlpEntityIdentityResolver,
+                                        OtlpIngestionRetryService retryService) {
         this.restTemplate = restTemplate;
         this.greptimePropertiesProvider = greptimePropertiesProvider;
         this.otlpLogProtocolAdapter = otlpLogProtocolAdapter;
         this.greptimeOtlpForwarder = greptimeOtlpForwarder;
         this.otlpCorrelationEnricher = otlpCorrelationEnricher;
+        this.errorResponseFactory = errorResponseFactory;
+        this.requestContextResolver = requestContextResolver;
+        this.auditService = auditService;
+        this.governanceService = governanceService;
+        this.quotaService = quotaService;
         this.observabilitySignalIntakeGateway = observabilitySignalIntakeGateway;
+        this.otlpEntityIdentityResolver = otlpEntityIdentityResolver;
+        this.retryService = retryService == null ? new OtlpIngestionRetryService() : retryService;
     }
 
     @Override
     public ResponseEntity<byte[]> ingestMetricsHttp(byte[] content, HttpHeaders requestHeaders) {
-        MediaType contentType = requestHeaders.getContentType();
+        long startedAtNanos = System.nanoTime();
+        HttpHeaders safeRequestHeaders = safeHeaders(requestHeaders);
+        byte[] safeContent = safeContent(content);
+        MediaType contentType = safeRequestHeaders.getContentType();
+        OtlpCorrelationContext correlationContext = requestContextResolver.currentCorrelationContext();
+        long requestBytes = requestBytes(content);
+        Long signalItems = null;
         try {
-            ExportMetricsServiceRequest request = decodeMetricsRequest(content, contentType, requestHeaders);
-            ResponseEntity<byte[]> response = proxySignalHttp(content, requestHeaders, "/v1/otlp/v1/metrics", false);
+            quotaService.checkRequestBytes("metrics", "http", requestBytes);
+            byte[] normalizedContent = normalizeHttpContentForQuota("metrics", "http", safeContent, safeRequestHeaders);
+            ExportMetricsServiceRequest request = normalizeAndEnrichMetricRequest(
+                    decodeMetricsRequest(normalizedContent, contentType), correlationContext);
+            signalItems = quotaService.countMetricItems(request);
+            quotaService.checkMetricItems("http", signalItems);
+            OtlpIngestionGovernanceService.Decision governanceDecision =
+                    governanceService.evaluateMetrics("http", request);
+            if (governanceDecision.dropped()) {
+                auditService.recordDropped("metrics", "http", correlationContext, requestBytes, signalItems,
+                        governanceDecision.reason(), durationMillis(startedAtNanos));
+                return emptySignalHttpSuccess(contentType, safeRequestHeaders.getAccept(), false);
+            }
+            ResponseEntity<byte[]> response = proxySignalHttp(
+                    safeContent, safeRequestHeaders, "/v1/otlp/v1/metrics", false, correlationContext);
             if (response.getStatusCode().is2xxSuccessful()) {
                 recordMetricIntake(request);
+                auditService.recordAccepted("metrics", "http", correlationContext, requestBytes, signalItems,
+                        durationMillis(startedAtNanos));
             }
             return response;
         } catch (StatusRuntimeException ex) {
-            HttpStatus status = switch (ex.getStatus().getCode()) {
-                case INVALID_ARGUMENT -> HttpStatus.BAD_REQUEST;
-                case UNAVAILABLE -> HttpStatus.SERVICE_UNAVAILABLE;
-                default -> HttpStatus.INTERNAL_SERVER_ERROR;
-            };
-            return errorResponse(contentType, status, ex.getStatus().getDescription());
+            auditService.recordRejected("metrics", "http", correlationContext, requestBytes, ex, signalItems,
+                    durationMillis(startedAtNanos));
+            return errorResponseFactory.httpErrorResponse(contentType, safeRequestHeaders.getAccept(), ex);
+        }
+    }
+
+    @Override
+    public ResponseEntity<byte[]> ingestLogsHttp(byte[] content, HttpHeaders requestHeaders) {
+        long startedAtNanos = System.nanoTime();
+        HttpHeaders safeRequestHeaders = safeHeaders(requestHeaders);
+        byte[] safeContent = safeContent(content);
+        MediaType contentType = safeRequestHeaders.getContentType();
+        OtlpCorrelationContext correlationContext = requestContextResolver.currentCorrelationContext();
+        long requestBytes = requestBytes(content);
+        Long signalItems = null;
+        try {
+            quotaService.checkRequestBytes("logs", "http", requestBytes);
+            byte[] normalizedContent = normalizeHttpContentForQuota("logs", "http", safeContent, safeRequestHeaders);
+            HttpHeaders normalizedHeaders = withoutContentEncoding(safeRequestHeaders);
+            byte[] enrichedContent = otlpCorrelationEnricher.enrichLogsHttp(
+                    normalizedContent, normalizedHeaders, correlationContext);
+            ExportLogsServiceRequest enrichedRequest = ExportLogsServiceRequest.parseFrom(enrichedContent);
+            ExportLogsServiceRequest resolvedRequest = otlpEntityIdentityResolver.enrichLogs(
+                    enrichedRequest, correlationContext.workspaceId());
+            ExportLogsServiceRequest redactedRequest = redactLogRequest(resolvedRequest);
+            byte[] redactedContent = redactedRequest.toByteArray();
+            signalItems = quotaService.countLogItems(redactedRequest);
+            quotaService.checkLogItems("http", signalItems);
+            OtlpIngestionGovernanceService.Decision governanceDecision =
+                    governanceService.evaluateLogs("http", redactedRequest);
+            if (governanceDecision.dropped()) {
+                auditService.recordDropped("logs", "http", correlationContext, requestBytes, signalItems,
+                        governanceDecision.reason(), durationMillis(startedAtNanos));
+                return emptyLogsHttpSuccess(contentType, safeRequestHeaders.getAccept());
+            }
+            ResponseEntity<byte[]> forwardResponse = greptimeOtlpForwarder.forwardLogsProtobuf(redactedContent);
+            if (forwardResponse == null) {
+                throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend returned no response.")
+                        .asRuntimeException();
+            }
+            if (!forwardResponse.getStatusCode().is2xxSuccessful()) {
+                throw backendStatusException(forwardResponse.getStatusCode(), forwardResponse.getHeaders());
+            }
+            ResponseEntity<byte[]> successResponse =
+                    logsHttpSuccess(contentType, safeRequestHeaders.getAccept(), forwardResponse.getBody());
+            publishRealtimeSignalsBestEffort(redactedRequest);
+            auditService.recordAccepted("logs", "http", correlationContext, requestBytes, signalItems,
+                    durationMillis(startedAtNanos));
+            return successResponse;
+        } catch (StatusRuntimeException ex) {
+            auditService.recordRejected("logs", "http", correlationContext, requestBytes, ex, signalItems,
+                    durationMillis(startedAtNanos));
+            return errorResponseFactory.httpErrorResponse(contentType, safeRequestHeaders.getAccept(), ex);
+        } catch (IllegalArgumentException ex) {
+            auditService.recordRejected("logs", "http", correlationContext, requestBytes,
+                    io.grpc.Status.Code.INVALID_ARGUMENT, defaultErrorMessage(ex), signalItems,
+                    durationMillis(startedAtNanos));
+            return errorResponseFactory.httpErrorResponse(
+                    contentType, safeRequestHeaders.getAccept(), HttpStatus.BAD_REQUEST, defaultErrorMessage(ex));
+        } catch (InvalidProtocolBufferException ex) {
+            auditService.recordRejected("logs", "http", correlationContext, requestBytes,
+                    io.grpc.Status.Code.INVALID_ARGUMENT, "Malformed OTLP logs payload.", signalItems,
+                    durationMillis(startedAtNanos));
+            return errorResponseFactory.httpErrorResponse(
+                    contentType, safeRequestHeaders.getAccept(), HttpStatus.BAD_REQUEST,
+                    "Malformed OTLP logs payload.");
+        } catch (Exception ex) {
+            log.error("Unexpected error ingesting OTLP HTTP logs: {}", ex.getMessage(), ex);
+            auditService.recordRejected("logs", "http", correlationContext, requestBytes,
+                    io.grpc.Status.Code.INTERNAL, defaultErrorMessage(ex), signalItems,
+                    durationMillis(startedAtNanos));
+            return errorResponseFactory.httpErrorResponse(
+                    contentType, safeRequestHeaders.getAccept(), HttpStatus.INTERNAL_SERVER_ERROR,
+                    defaultErrorMessage(ex));
         }
     }
 
     @Override
     public ResponseEntity<byte[]> ingestTracesHttp(byte[] content, HttpHeaders requestHeaders) {
-        MediaType contentType = requestHeaders.getContentType();
-        OtlpCorrelationContext correlationContext = OtlpCorrelationContext.empty();
+        long startedAtNanos = System.nanoTime();
+        HttpHeaders safeRequestHeaders = safeHeaders(requestHeaders);
+        byte[] safeContent = safeContent(content);
+        MediaType contentType = safeRequestHeaders.getContentType();
+        OtlpCorrelationContext correlationContext = requestContextResolver.currentCorrelationContext();
+        long requestBytes = requestBytes(content);
+        Long signalItems = null;
         try {
+            quotaService.checkRequestBytes("traces", "http", requestBytes);
+            byte[] normalizedContent = normalizeHttpContentForQuota("traces", "http", safeContent, safeRequestHeaders);
             ExportTraceServiceRequest request = normalizeAndEnrichTraceRequest(
-                    decodeTraceRequest(content, contentType, requestHeaders), correlationContext);
+                    decodeTraceRequest(normalizedContent, contentType), correlationContext);
+            signalItems = quotaService.countTraceItems(request);
+            quotaService.checkTraceItems("http", signalItems);
+            OtlpIngestionGovernanceService.Decision governanceDecision =
+                    governanceService.evaluateTraces("http", request);
+            if (governanceDecision.dropped()) {
+                auditService.recordDropped("traces", "http", correlationContext, requestBytes, signalItems,
+                        governanceDecision.reason(), durationMillis(startedAtNanos));
+                return emptySignalHttpSuccess(contentType, safeRequestHeaders.getAccept(), true);
+            }
             ResponseEntity<byte[]> response = proxySignalHttp(
-                    content, requestHeaders, "/v1/otlp/v1/traces", true, correlationContext);
+                    safeContent, safeRequestHeaders, "/v1/otlp/v1/traces", true, correlationContext);
             if (response.getStatusCode().is2xxSuccessful()) {
                 recordTraceIntake(request);
+                auditService.recordAccepted("traces", "http", correlationContext, requestBytes, signalItems,
+                        durationMillis(startedAtNanos));
             }
             return response;
         } catch (StatusRuntimeException ex) {
-            HttpStatus status = switch (ex.getStatus().getCode()) {
-                case INVALID_ARGUMENT -> HttpStatus.BAD_REQUEST;
-                case UNAVAILABLE -> HttpStatus.SERVICE_UNAVAILABLE;
-                default -> HttpStatus.INTERNAL_SERVER_ERROR;
-            };
-            return errorResponse(contentType, status, ex.getStatus().getDescription());
+            auditService.recordRejected("traces", "http", correlationContext, requestBytes, ex, signalItems,
+                    durationMillis(startedAtNanos));
+            return errorResponseFactory.httpErrorResponse(contentType, safeRequestHeaders.getAccept(), ex);
         }
     }
 
     @Override
     public ExportMetricsServiceResponse ingestMetricsGrpc(ExportMetricsServiceRequest request) {
-        byte[] response = proxySignalBinary(request.toByteArray(), "/v1/otlp/v1/metrics", false);
-        recordMetricIntake(request);
+        long startedAtNanos = System.nanoTime();
+        OtlpCorrelationContext correlationContext = requestContextResolver.currentCorrelationContext();
+        long requestBytes = requestBytes(request);
+        Long signalItems = null;
         try {
-            return response.length == 0 ? ExportMetricsServiceResponse.getDefaultInstance()
+            quotaService.checkRequestBytes("metrics", "grpc", requestBytes);
+            ExportMetricsServiceRequest redactedRequest = normalizeAndEnrichMetricRequest(request, correlationContext);
+            signalItems = quotaService.countMetricItems(redactedRequest);
+            quotaService.checkMetricItems("grpc", signalItems);
+            OtlpIngestionGovernanceService.Decision governanceDecision =
+                    governanceService.evaluateMetrics("grpc", redactedRequest);
+            if (governanceDecision.dropped()) {
+                auditService.recordDropped("metrics", "grpc", correlationContext, requestBytes, signalItems,
+                        governanceDecision.reason(), durationMillis(startedAtNanos));
+                return ExportMetricsServiceResponse.getDefaultInstance();
+            }
+            byte[] response = proxySignalBinary(redactedRequest.toByteArray(), "/v1/otlp/v1/metrics", false,
+                    correlationContext);
+            ExportMetricsServiceResponse parsedResponse = response.length == 0
+                    ? ExportMetricsServiceResponse.getDefaultInstance()
                     : ExportMetricsServiceResponse.parseFrom(response);
+            recordMetricIntake(redactedRequest);
+            auditService.recordAccepted("metrics", "grpc", correlationContext, requestBytes, signalItems,
+                    durationMillis(startedAtNanos));
+            return parsedResponse;
+        } catch (StatusRuntimeException ex) {
+            auditService.recordRejected("metrics", "grpc", correlationContext, requestBytes, ex, signalItems,
+                    durationMillis(startedAtNanos));
+            throw ex;
         } catch (InvalidProtocolBufferException e) {
+            auditService.recordRejected("metrics", "grpc", correlationContext, requestBytes,
+                    io.grpc.Status.Code.INTERNAL, "OTLP metrics response is malformed.", signalItems,
+                    durationMillis(startedAtNanos));
             throw io.grpc.Status.INTERNAL.withDescription("OTLP metrics response is malformed.").withCause(e)
                     .asRuntimeException();
         }
@@ -228,22 +420,58 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
 
     @Override
     public ExportLogsServiceResponse ingestLogsGrpc(ExportLogsServiceRequest request) {
+        long startedAtNanos = System.nanoTime();
+        OtlpCorrelationContext correlationContext = requestContextResolver.currentCorrelationContext();
+        long requestBytes = requestBytes(request);
+        Long signalItems = null;
         try {
+            quotaService.checkRequestBytes("logs", "grpc", requestBytes);
             ExportLogsServiceRequest enrichedRequest = otlpCorrelationEnricher.enrichLogs(
-                    request, OtlpCorrelationContext.empty());
-            byte[] response = greptimeOtlpForwarder.forwardLogsGrpc(enrichedRequest);
-            otlpLogProtocolAdapter.publishRealtimeSignals(enrichedRequest);
-            return response.length == 0 ? ExportLogsServiceResponse.getDefaultInstance()
+                    request, correlationContext);
+            ExportLogsServiceRequest resolvedRequest = otlpEntityIdentityResolver.enrichLogs(
+                    enrichedRequest, correlationContext.workspaceId());
+            ExportLogsServiceRequest redactedRequest = redactLogRequest(resolvedRequest);
+            signalItems = quotaService.countLogItems(redactedRequest);
+            quotaService.checkLogItems("grpc", signalItems);
+            OtlpIngestionGovernanceService.Decision governanceDecision =
+                    governanceService.evaluateLogs("grpc", redactedRequest);
+            if (governanceDecision.dropped()) {
+                auditService.recordDropped("logs", "grpc", correlationContext, requestBytes, signalItems,
+                        governanceDecision.reason(), durationMillis(startedAtNanos));
+                return ExportLogsServiceResponse.getDefaultInstance();
+            }
+            byte[] response = greptimeOtlpForwarder.forwardLogsGrpc(redactedRequest);
+            if (response == null) {
+                throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend returned no response.")
+                        .asRuntimeException();
+            }
+            ExportLogsServiceResponse parsedResponse = response.length == 0
+                    ? ExportLogsServiceResponse.getDefaultInstance()
                     : ExportLogsServiceResponse.parseFrom(response);
+            publishRealtimeSignalsBestEffort(redactedRequest);
+            auditService.recordAccepted("logs", "grpc", correlationContext, requestBytes, signalItems,
+                    durationMillis(startedAtNanos));
+            return parsedResponse;
         } catch (StatusRuntimeException ex) {
+            auditService.recordRejected("logs", "grpc", correlationContext, requestBytes, ex, signalItems,
+                    durationMillis(startedAtNanos));
             throw ex;
         } catch (InvalidProtocolBufferException ex) {
+            auditService.recordRejected("logs", "grpc", correlationContext, requestBytes,
+                    io.grpc.Status.Code.INTERNAL, "OTLP logs response is malformed.", signalItems,
+                    durationMillis(startedAtNanos));
             throw io.grpc.Status.INTERNAL.withDescription("OTLP logs response is malformed.").withCause(ex)
                     .asRuntimeException();
         } catch (IllegalArgumentException ex) {
+            auditService.recordRejected("logs", "grpc", correlationContext, requestBytes,
+                    io.grpc.Status.Code.INVALID_ARGUMENT, defaultErrorMessage(ex), signalItems,
+                    durationMillis(startedAtNanos));
             throw io.grpc.Status.INVALID_ARGUMENT.withDescription(defaultErrorMessage(ex)).withCause(ex)
                     .asRuntimeException();
         } catch (Exception ex) {
+            auditService.recordRejected("logs", "grpc", correlationContext, requestBytes,
+                    io.grpc.Status.Code.INTERNAL, defaultErrorMessage(ex), signalItems,
+                    durationMillis(startedAtNanos));
             throw io.grpc.Status.INTERNAL.withDescription(defaultErrorMessage(ex)).withCause(ex)
                     .asRuntimeException();
         }
@@ -251,14 +479,39 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
 
     @Override
     public ExportTraceServiceResponse ingestTracesGrpc(ExportTraceServiceRequest request) {
-        ExportTraceServiceRequest enrichedRequest = normalizeAndEnrichTraceRequest(
-                request, OtlpCorrelationContext.empty());
-        byte[] response = proxySignalBinary(enrichedRequest.toByteArray(), "/v1/otlp/v1/traces", true);
-        recordTraceIntake(enrichedRequest);
+        long startedAtNanos = System.nanoTime();
+        OtlpCorrelationContext correlationContext = requestContextResolver.currentCorrelationContext();
+        long requestBytes = requestBytes(request);
+        Long signalItems = null;
         try {
-            return response.length == 0 ? ExportTraceServiceResponse.getDefaultInstance()
+            quotaService.checkRequestBytes("traces", "grpc", requestBytes);
+            ExportTraceServiceRequest enrichedRequest = normalizeAndEnrichTraceRequest(request, correlationContext);
+            signalItems = quotaService.countTraceItems(enrichedRequest);
+            quotaService.checkTraceItems("grpc", signalItems);
+            OtlpIngestionGovernanceService.Decision governanceDecision =
+                    governanceService.evaluateTraces("grpc", enrichedRequest);
+            if (governanceDecision.dropped()) {
+                auditService.recordDropped("traces", "grpc", correlationContext, requestBytes, signalItems,
+                        governanceDecision.reason(), durationMillis(startedAtNanos));
+                return ExportTraceServiceResponse.getDefaultInstance();
+            }
+            byte[] response = proxySignalBinary(
+                    enrichedRequest.toByteArray(), "/v1/otlp/v1/traces", true, correlationContext);
+            ExportTraceServiceResponse parsedResponse = response.length == 0
+                    ? ExportTraceServiceResponse.getDefaultInstance()
                     : ExportTraceServiceResponse.parseFrom(response);
+            recordTraceIntake(enrichedRequest);
+            auditService.recordAccepted("traces", "grpc", correlationContext, requestBytes, signalItems,
+                    durationMillis(startedAtNanos));
+            return parsedResponse;
+        } catch (StatusRuntimeException ex) {
+            auditService.recordRejected("traces", "grpc", correlationContext, requestBytes, ex, signalItems,
+                    durationMillis(startedAtNanos));
+            throw ex;
         } catch (InvalidProtocolBufferException e) {
+            auditService.recordRejected("traces", "grpc", correlationContext, requestBytes,
+                    io.grpc.Status.Code.INTERNAL, "OTLP trace response is malformed.", signalItems,
+                    durationMillis(startedAtNanos));
             throw io.grpc.Status.INTERNAL.withDescription("OTLP trace response is malformed.").withCause(e)
                     .asRuntimeException();
         }
@@ -269,53 +522,155 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
         return List.of("metrics", "logs", "traces");
     }
 
-    private ExportMetricsServiceRequest decodeMetricsRequest(byte[] content, MediaType contentType, HttpHeaders headers) {
+    private ResponseEntity<byte[]> emptyLogsHttpSuccess(MediaType requestContentType, List<MediaType> acceptTypes) {
+        return logsHttpSuccess(requestContentType, acceptTypes,
+                ExportLogsServiceResponse.getDefaultInstance().toByteArray());
+    }
+
+    private ResponseEntity<byte[]> logsHttpSuccess(MediaType requestContentType, List<MediaType> acceptTypes,
+                                                   byte[] upstreamBody) {
+        MediaType responseContentType = resolveResponseContentType(requestContentType, acceptTypes);
+        byte[] protobufBody = upstreamBody == null || upstreamBody.length == 0
+                ? ExportLogsServiceResponse.getDefaultInstance().toByteArray()
+                : upstreamBody;
+        ExportLogsServiceResponse parsedResponse = parseLogsResponseBody(protobufBody);
+        if (MediaType.APPLICATION_JSON.includes(responseContentType)) {
+            return ResponseEntity.ok()
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(normalizeLogsResponseBodyForClient(parsedResponse));
+        }
+        return ResponseEntity.ok()
+                .contentType(MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF))
+                .body(protobufBody);
+    }
+
+    private ExportLogsServiceResponse parseLogsResponseBody(byte[] upstreamBody) {
+        byte[] safeBody = upstreamBody == null ? new byte[0] : upstreamBody;
+        try {
+            return safeBody.length == 0
+                    ? ExportLogsServiceResponse.getDefaultInstance()
+                    : ExportLogsServiceResponse.parseFrom(safeBody);
+        } catch (InvalidProtocolBufferException ex) {
+            throw io.grpc.Status.INTERNAL.withDescription("OTLP logs response is malformed.").withCause(ex)
+                    .asRuntimeException();
+        }
+    }
+
+    private byte[] normalizeLogsResponseBodyForClient(ExportLogsServiceResponse response) {
+        try {
+            return JsonFormat.printer().print(response).getBytes(StandardCharsets.UTF_8);
+        } catch (InvalidProtocolBufferException ex) {
+            throw io.grpc.Status.INTERNAL.withDescription("OTLP logs response is malformed.").withCause(ex)
+                    .asRuntimeException();
+        }
+    }
+
+    private ExportMetricsServiceRequest decodeMetricsRequest(byte[] content, MediaType contentType) {
         try {
-            byte[] normalizedContent = maybeDecompress(content, headers);
             if (contentType != null && MediaType.APPLICATION_JSON.includes(contentType)) {
                 ExportMetricsServiceRequest.Builder builder = ExportMetricsServiceRequest.newBuilder();
                 JsonFormat.parser().ignoringUnknownFields()
-                        .merge(normalizeOtlpJson(new String(normalizedContent, StandardCharsets.UTF_8)), builder);
+                        .merge(normalizeOtlpJson(new String(content, StandardCharsets.UTF_8)), builder);
                 return builder.build();
             }
-            return ExportMetricsServiceRequest.parseFrom(normalizedContent);
+            return ExportMetricsServiceRequest.parseFrom(content);
         } catch (InvalidProtocolBufferException ex) {
             throw io.grpc.Status.INVALID_ARGUMENT.withDescription("Malformed OTLP metrics payload.")
                     .withCause(ex).asRuntimeException();
         }
     }
 
-    private ExportTraceServiceRequest decodeTraceRequest(byte[] content, MediaType contentType, HttpHeaders headers) {
+    private ExportTraceServiceRequest decodeTraceRequest(byte[] content, MediaType contentType) {
         try {
-            byte[] normalizedContent = maybeDecompress(content, headers);
             if (contentType != null && MediaType.APPLICATION_JSON.includes(contentType)) {
                 ExportTraceServiceRequest.Builder builder = ExportTraceServiceRequest.newBuilder();
                 JsonFormat.parser().ignoringUnknownFields()
-                        .merge(normalizeOtlpJson(new String(normalizedContent, StandardCharsets.UTF_8)), builder);
+                        .merge(normalizeOtlpJson(new String(content, StandardCharsets.UTF_8)), builder);
                 return builder.build();
             }
-            return ExportTraceServiceRequest.parseFrom(normalizedContent);
+            return ExportTraceServiceRequest.parseFrom(content);
         } catch (InvalidProtocolBufferException ex) {
             throw io.grpc.Status.INVALID_ARGUMENT.withDescription("Malformed OTLP trace payload.")
                     .withCause(ex).asRuntimeException();
         }
     }
 
+    private byte[] normalizeHttpContentForQuota(String signal, String protocol, byte[] content, HttpHeaders headers) {
+        if (!isGzipEncoded(headers)) {
+            return content;
+        }
+        return decompressGzip(content, signal, protocol);
+    }
+
+    private HttpHeaders safeHeaders(HttpHeaders headers) {
+        return headers == null ? new HttpHeaders() : headers;
+    }
+
+    private byte[] safeContent(byte[] content) {
+        return content == null ? new byte[0] : content;
+    }
+
+    private HttpHeaders withoutContentEncoding(HttpHeaders headers) {
+        HttpHeaders normalizedHeaders = new HttpHeaders();
+        if (headers != null) {
+            normalizedHeaders.putAll(headers);
+            normalizedHeaders.remove(CONTENT_ENCODING);
+            normalizedHeaders.remove(HttpHeaders.CONTENT_ENCODING);
+        }
+        return normalizedHeaders;
+    }
+
+    private boolean isGzipEncoded(HttpHeaders headers) {
+        List<String> contentEncodings = headers == null ? null : headers.get(CONTENT_ENCODING);
+        return contentEncodings != null && contentEncodings.stream().anyMatch(this::isGzipContentEncoding);
+    }
+
     private byte[] maybeDecompress(byte[] content, HttpHeaders headers) {
         if (content == null || content.length == 0 || headers == null) {
             return content;
         }
-        String contentEncoding = headers.getFirst(CONTENT_ENCODING);
-        if (!StringUtils.equalsIgnoreCase(contentEncoding, CONTENT_ENCODING_GZIP)) {
+        if (!isGzipEncoded(headers)) {
+            return content;
+        }
+        return decompressGzip(content, null, null);
+    }
+
+    private boolean isGzipContentEncoding(String contentEncoding) {
+        String[] encodings = StringUtils.split(contentEncoding, ',');
+        if (encodings == null || encodings.length == 0) {
+            return false;
+        }
+        for (String encoding : encodings) {
+            if (StringUtils.equalsIgnoreCase(StringUtils.trimToEmpty(encoding), CONTENT_ENCODING_GZIP)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private byte[] decompressGzip(byte[] content, String signal, String protocol) {
+        if (content == null || content.length == 0) {
             return content;
         }
         try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content);
              GZIPInputStream gzipInputStream = new GZIPInputStream(inputStream);
              ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
-            gzipInputStream.transferTo(outputStream);
+            byte[] buffer = new byte[GZIP_DECOMPRESSION_BUFFER_BYTES];
+            int read;
+            while ((read = gzipInputStream.read(buffer)) != -1) {
+                if (signal != null && protocol != null) {
+                    quotaService.checkRequestBytes(signal, protocol, (long) outputStream.size() + read);
+                }
+                outputStream.write(buffer, 0, read);
+            }
             return outputStream.toByteArray();
+        } catch (StatusRuntimeException ex) {
+            throw ex;
         } catch (Exception ex) {
-            throw io.grpc.Status.INVALID_ARGUMENT.withDescription("Malformed gzip-compressed OTLP payload.")
+            String description = StringUtils.isNotBlank(signal) && StringUtils.isNotBlank(protocol)
+                    ? "Malformed gzip-compressed OTLP " + signal + " " + protocol + " payload."
+                    : "Malformed gzip-compressed OTLP payload.";
+            throw io.grpc.Status.INVALID_ARGUMENT.withDescription(description)
                     .withCause(ex).asRuntimeException();
         }
     }
@@ -325,20 +680,26 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
             return;
         }
         for (ResourceMetrics resourceMetrics : request.getResourceMetricsList()) {
-            Map<String, String> resourceAttributes = toStringMap(resourceMetrics.getResource().getAttributesList());
+            Map<String, String> resourceAttributes = requestContextResolver.withWorkspaceResourceAttributes(
+                    toStringMap(resourceMetrics.getResource().getAttributesList()));
             for (ScopeMetrics scopeMetrics : resourceMetrics.getScopeMetricsList()) {
                 for (Metric metric : scopeMetrics.getMetricsList()) {
                     List<MetricObservation> observations = extractMetricObservations(metric);
                     for (MetricObservation observation : observations) {
-                        observabilitySignalIntakeGateway.recordOtlpMetricIntake(
-                                resourceAttributes,
-                                observation.observedAt(),
-                                StringUtils.trimToNull(metric.getName()),
-                                observation.metricType(),
-                                StringUtils.trimToNull(metric.getUnit()),
-                                observation.value(),
-                                observation.attributes()
-                        );
+                        try {
+                            observabilitySignalIntakeGateway.recordOtlpMetricIntake(
+                                    resourceAttributes,
+                                    observation.observedAt(),
+                                    StringUtils.trimToNull(metric.getName()),
+                                    observation.metricType(),
+                                    StringUtils.trimToNull(metric.getUnit()),
+                                    observation.value(),
+                                    observation.attributes()
+                            );
+                        } catch (RuntimeException ex) {
+                            log.warn("Failed to record OTLP metric intake read model for metric {}: {}",
+                                    StringUtils.trimToNull(metric.getName()), ex.toString());
+                        }
                     }
                 }
             }
@@ -350,59 +711,114 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
             return;
         }
         for (ResourceSpans resourceSpans : request.getResourceSpansList()) {
-            Map<String, String> resourceAttributes = toStringMap(resourceSpans.getResource().getAttributesList());
+            Map<String, String> resourceAttributes = requestContextResolver.withWorkspaceResourceAttributes(
+                    toStringMap(resourceSpans.getResource().getAttributesList()));
             for (ScopeSpans scopeSpans : resourceSpans.getScopeSpansList()) {
                 for (Span span : scopeSpans.getSpansList()) {
-                    observabilitySignalIntakeGateway.recordOtlpTraceIntake(
-                            resourceAttributes,
-                            nanosToMillis(span.getStartTimeUnixNano()),
-                            HexFormat.of().formatHex(span.getTraceId().toByteArray()),
-                            HexFormat.of().formatHex(span.getSpanId().toByteArray()),
-                            StringUtils.trimToNull(span.getName()),
-                            span.getStatus().getCode().name().toLowerCase(Locale.ROOT),
-                            toStringMap(span.getAttributesList())
-                    );
+                    if (!hasValidTraceIdentifiers(span)) {
+                        continue;
+                    }
+                    String traceId = HexFormat.of().formatHex(span.getTraceId().toByteArray());
+                    String spanId = HexFormat.of().formatHex(span.getSpanId().toByteArray());
+                    try {
+                        observabilitySignalIntakeGateway.recordOtlpTraceIntake(
+                                resourceAttributes,
+                                nanosToMillis(span.getStartTimeUnixNano()),
+                                traceId,
+                                spanId,
+                                StringUtils.trimToNull(span.getName()),
+                                span.getStatus().getCode().name().toLowerCase(Locale.ROOT),
+                                toStringMap(span.getAttributesList())
+                        );
+                    } catch (RuntimeException ex) {
+                        log.warn("Failed to record OTLP trace intake read model for trace {} span {}: {}",
+                                traceId, spanId, ex.toString());
+                    }
                 }
             }
         }
     }
 
+    private boolean hasValidTraceIdentifiers(Span span) {
+        return span != null
+                && span.getTraceId().size() == OTLP_TRACE_ID_BYTES
+                && span.getSpanId().size() == OTLP_SPAN_ID_BYTES
+                && !isAllZero(span.getTraceId().toByteArray())
+                && !isAllZero(span.getSpanId().toByteArray());
+    }
+
+    private boolean isAllZero(byte[] value) {
+        if (value == null || value.length == 0) {
+            return true;
+        }
+        for (byte item : value) {
+            if (item != 0) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private void publishRealtimeSignalsBestEffort(ExportLogsServiceRequest request) {
+        try {
+            otlpLogProtocolAdapter.publishRealtimeSignals(request);
+        } catch (RuntimeException ex) {
+            log.warn("Failed to publish OTLP log realtime signals: {}", ex.toString());
+        }
+    }
+
     private ResponseEntity<byte[]> proxySignalHttp(byte[] content, HttpHeaders requestHeaders, String path,
                                                    boolean traceSignal) {
-        return proxySignalHttp(content, requestHeaders, path, traceSignal, OtlpCorrelationContext.empty());
+        return proxySignalHttp(
+                content, requestHeaders, path, traceSignal, requestContextResolver.currentCorrelationContext());
     }
 
     private ResponseEntity<byte[]> proxySignalHttp(byte[] content, HttpHeaders requestHeaders, String path,
                                                    boolean traceSignal, OtlpCorrelationContext correlationContext) {
         MediaType contentType = requestHeaders.getContentType();
-        try {
-            byte[] responseBody = proxySignalInternal(content, path, traceSignal, contentType,
-                    requestHeaders.getAccept(), requestHeaders, correlationContext);
-            return ResponseEntity.ok()
-                    .contentType(resolveResponseContentType(contentType, requestHeaders.getAccept()))
-                    .body(responseBody);
-        } catch (StatusRuntimeException ex) {
-            HttpStatus status = switch (ex.getStatus().getCode()) {
-                case INVALID_ARGUMENT -> HttpStatus.BAD_REQUEST;
-                case UNAVAILABLE -> HttpStatus.SERVICE_UNAVAILABLE;
-                default -> HttpStatus.INTERNAL_SERVER_ERROR;
-            };
-            return errorResponse(contentType, status, ex.getStatus().getDescription());
-        }
+        byte[] responseBody = proxySignalInternal(content, path, traceSignal, contentType,
+                requestHeaders.getAccept(), requestHeaders, correlationContext);
+        return ResponseEntity.ok()
+                .contentType(resolveResponseContentType(contentType, requestHeaders.getAccept()))
+                .body(responseBody);
+    }
+
+    private ResponseEntity<byte[]> emptySignalHttpSuccess(MediaType requestContentType, List<MediaType> acceptTypes,
+                                                         boolean traceSignal) {
+        MediaType responseContentType = resolveResponseContentType(requestContentType, acceptTypes);
+        byte[] responseBody = emptySignalResponseBody(responseContentType, traceSignal);
+        return ResponseEntity.ok()
+                .contentType(responseContentType)
+                .body(responseBody);
+    }
+
+    private byte[] emptySignalResponseBody(MediaType responseContentType, boolean traceSignal) {
+        byte[] protobufBody = traceSignal
+                ? ExportTraceServiceResponse.getDefaultInstance().toByteArray()
+                : ExportMetricsServiceResponse.getDefaultInstance().toByteArray();
+        if (responseContentType != null && MediaType.APPLICATION_JSON.includes(responseContentType)) {
+            return normalizeResponseBodyForClient(protobufBody, traceSignal);
+        }
+        return protobufBody;
     }
 
     private byte[] proxySignalBinary(byte[] content, String path, boolean traceSignal) {
+        return proxySignalBinary(content, path, traceSignal, requestContextResolver.currentCorrelationContext());
+    }
+
+    private byte[] proxySignalBinary(byte[] content, String path, boolean traceSignal,
+                                     OtlpCorrelationContext correlationContext) {
         return proxySignalInternal(content, path, traceSignal,
                 MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF),
                 List.of(MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF)),
                 null,
-                OtlpCorrelationContext.empty());
+                correlationContext);
     }
 
     private byte[] proxySignalInternal(byte[] content, String path, boolean traceSignal,
                                        MediaType contentType, List<MediaType> acceptTypes,
                                        HttpHeaders requestHeaders, OtlpCorrelationContext correlationContext) {
-        GreptimeProperties greptimeProperties = greptimePropertiesProvider.getIfAvailable();
+        GreptimeProperties greptimeProperties = greptimePropertiesOrUnavailable();
         if (greptimeProperties == null || !greptimeProperties.enabled()
                 || !StringUtils.isNotBlank(greptimeProperties.httpEndpoint())) {
             throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend is not configured.")
@@ -413,16 +829,16 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
             byte[] upstreamContent = normalizeRequestBodyForUpstream(
                     content, contentType, traceSignal, requestHeaders, correlationContext);
             HttpHeaders upstreamHeaders = new HttpHeaders();
-            if (contentType != null) {
-                upstreamHeaders.setContentType(resolveUpstreamContentType(contentType));
-            }
+            upstreamHeaders.setContentType(resolveUpstreamContentType(contentType));
             if (clientExpectsJson) {
                 upstreamHeaders.setAccept(List.of(MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF)));
-            } else if (acceptTypes != null && !acceptTypes.isEmpty()) {
-                upstreamHeaders.setAccept(acceptTypes);
+            } else {
+                List<MediaType> upstreamAcceptTypes = resolveUpstreamAcceptTypes(acceptTypes);
+                if (!upstreamAcceptTypes.isEmpty()) {
+                    upstreamHeaders.setAccept(upstreamAcceptTypes);
+                }
             }
-            upstreamHeaders.set(GREPTIME_DB_NAME_HEADER,
-                    StringUtils.defaultIfBlank(greptimeProperties.database(), DEFAULT_GREPTIME_DB_NAME));
+            upstreamHeaders.set(GREPTIME_DB_NAME_HEADER, database(greptimeProperties.database()));
             if (traceSignal) {
                 upstreamHeaders.set(GREPTIME_TRACE_TABLE_NAME_HEADER, DEFAULT_TRACES_TABLE_NAME);
                 upstreamHeaders.set(GREPTIME_PIPELINE_NAME_HEADER, DEFAULT_TRACE_PIPELINE);
@@ -437,21 +853,29 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
             }
             addAuthenticationHeader(upstreamHeaders, greptimeProperties);
 
-            ResponseEntity<byte[]> response = restTemplate.exchange(
-                    greptimeProperties.httpEndpoint() + path,
+            ResponseEntity<byte[]> response = retryService.execute(() -> restTemplate.exchange(
+                    endpoint(greptimeProperties.httpEndpoint(), path),
                     HttpMethod.POST,
                     new HttpEntity<>(upstreamContent, upstreamHeaders),
                     byte[].class
-            );
-            if (!response.getStatusCode().is2xxSuccessful()) {
-                throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend returned " + response.getStatusCode())
+            ), retryableResponse -> retryableResponse == null
+                    || retryService.isRetryableStatus(retryableResponse.getStatusCode()));
+            if (response == null) {
+                throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend returned no response.")
                         .asRuntimeException();
             }
+            if (!response.getStatusCode().is2xxSuccessful()) {
+                throw backendStatusException(response.getStatusCode(), response.getHeaders());
+            }
             byte[] responseBody = response.getBody() == null ? new byte[0] : response.getBody();
+            validateSignalResponseBody(responseBody, traceSignal);
             if (!clientExpectsJson) {
                 return responseBody;
             }
             return normalizeResponseBodyForClient(responseBody, traceSignal);
+        } catch (HttpStatusCodeException ex) {
+            log.error("Failed to proxy OTLP signal {}: {}", traceSignal ? "traces" : "metrics", ex.getMessage(), ex);
+            throw backendStatusException(ex.getStatusCode(), ex.getResponseHeaders());
         } catch (RestClientException ex) {
             log.error("Failed to proxy OTLP signal {}: {}", traceSignal ? "traces" : "metrics", ex.getMessage(), ex);
             throw io.grpc.Status.UNAVAILABLE.withDescription(defaultErrorMessage(ex)).withCause(ex)
@@ -459,19 +883,32 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
         }
     }
 
+    private GreptimeProperties greptimePropertiesOrUnavailable() {
+        try {
+            return greptimePropertiesProvider.getIfAvailable();
+        } catch (RuntimeException ex) {
+            log.warn("Failed to resolve Greptime OTLP backend properties: {}", ex.toString());
+            throw io.grpc.Status.UNAVAILABLE.withDescription("OTLP backend is not configured.")
+                    .withCause(ex)
+                    .asRuntimeException();
+        }
+    }
+
     private byte[] normalizeRequestBodyForUpstream(byte[] content, MediaType contentType, boolean traceSignal,
                                                    HttpHeaders requestHeaders,
                                                    OtlpCorrelationContext correlationContext) {
         byte[] normalizedContent = maybeDecompress(content, requestHeaders);
         if (contentType == null || !MediaType.APPLICATION_JSON.includes(contentType)) {
-            if (!traceSignal) {
-                return normalizedContent;
-            }
             try {
+                if (!traceSignal) {
+                    return normalizeAndEnrichMetricRequest(
+                            ExportMetricsServiceRequest.parseFrom(normalizedContent), correlationContext).toByteArray();
+                }
                 return normalizeAndEnrichTraceRequest(
                         ExportTraceServiceRequest.parseFrom(normalizedContent), correlationContext).toByteArray();
             } catch (InvalidProtocolBufferException ex) {
-                throw io.grpc.Status.INVALID_ARGUMENT.withDescription("Malformed OTLP trace payload.")
+                throw io.grpc.Status.INVALID_ARGUMENT.withDescription(
+                                traceSignal ? "Malformed OTLP trace payload." : "Malformed OTLP metrics payload.")
                         .withCause(ex).asRuntimeException();
             }
         }
@@ -485,100 +922,589 @@ public class OtlpGrpcIngestionServiceImpl implements OtlpGrpcIngestionService {
             ExportMetricsServiceRequest.Builder builder = ExportMetricsServiceRequest.newBuilder();
             JsonFormat.parser().ignoringUnknownFields()
                     .merge(normalizeOtlpJson(new String(normalizedContent, StandardCharsets.UTF_8)), builder);
-            return builder.build().toByteArray();
+            return normalizeAndEnrichMetricRequest(builder.build(), correlationContext).toByteArray();
         } catch (InvalidProtocolBufferException ex) {
             throw io.grpc.Status.INVALID_ARGUMENT.withDescription("Malformed OTLP JSON request.")
                     .withCause(ex).asRuntimeException();
         }
     }
 
+    private ExportMetricsServiceRequest normalizeAndEnrichMetricRequest(ExportMetricsServiceRequest request,
+                                                                        OtlpCorrelationContext correlationContext) {
+        OtlpCorrelationContext safeContext = correlationContext == null
+                ? OtlpCorrelationContext.empty()
+                : correlationContext;
+        ExportMetricsServiceRequest resolved =
+                otlpEntityIdentityResolver.enrichMetrics(request, safeContext.workspaceId());
+        return redactMetricRequest(otlpCorrelationEnricher.enrichMetrics(resolved, safeContext));
+    }
+
     private ExportTraceServiceRequest normalizeAndEnrichTraceRequest(ExportTraceServiceRequest request,
                                                                      OtlpCorrelationContext correlationContext) {
+        OtlpCorrelationContext safeContext = correlationContext == null
+                ? OtlpCorrelationContext.empty()
+                : correlationContext;
         ExportTraceServiceRequest normalized = normalizeTraceRequest(request);
-        return otlpCorrelationEnricher.enrichTraces(normalized, correlationContext);
+        ExportTraceServiceRequest resolved =
+                otlpEntityIdentityResolver.enrichTraces(normalized, safeContext.workspaceId());
+        return redactTraceRequest(otlpCorrelationEnricher.enrichTraces(resolved, safeContext));
+    }
+
+    private ExportMetricsServiceRequest redactMetricRequest(ExportMetricsServiceRequest request) {
+        if (request == null || request.getResourceMetricsCount() == 0) {
+            return request;
+        }
+        ExportMetricsServiceRequest.Builder requestBuilder = request.toBuilder().clearResourceMetrics();
+        for (ResourceMetrics resourceMetrics : request.getResourceMetricsList()) {
+            ResourceMetrics.Builder resourceBuilder = resourceMetrics.toBuilder().clearScopeMetrics()
+                    .setResource(resourceMetrics.getResource().toBuilder().clearAttributes()
+                            .addAllAttributes(redactAttributes(resourceMetrics.getResource().getAttributesList()))
+                            .build());
+            for (ScopeMetrics scopeMetrics : resourceMetrics.getScopeMetricsList()) {
+                ScopeMetrics.Builder scopeBuilder = scopeMetrics.toBuilder().clearMetrics()
+                        .setScope(redactInstrumentationScope(scopeMetrics.getScope()));
+                for (Metric metric : scopeMetrics.getMetricsList()) {
+                    scopeBuilder.addMetrics(redactMetric(metric));
+                }
+                resourceBuilder.addScopeMetrics(scopeBuilder.build());
+            }
+            requestBuilder.addResourceMetrics(resourceBuilder.build());
+        }
+        return requestBuilder.build();
+    }
+
+    private Metric redactMetric(Metric metric) {
+        if (metric == null) {
+            return null;
+        }
+        Metric.Builder metricBuilder = metric.toBuilder();
+        if (metric.hasGauge()) {
+            Gauge.Builder builder = metric.getGauge().toBuilder().clearDataPoints();
+            for (NumberDataPoint dataPoint : metric.getGauge().getDataPointsList()) {
+                builder.addDataPoints(redactNumberDataPoint(dataPoint));
+            }
+            metricBuilder.setGauge(builder.build());
+        }
+        if (metric.hasSum()) {
+            Sum.Builder builder = metric.getSum().toBuilder().clearDataPoints();
+            for (NumberDataPoint dataPoint : metric.getSum().getDataPointsList()) {
+                builder.addDataPoints(redactNumberDataPoint(dataPoint));
+            }
+            metricBuilder.setSum(builder.build());
+        }
+        if (metric.hasHistogram()) {
+            Histogram.Builder builder = metric.getHistogram().toBuilder().clearDataPoints();
+            for (HistogramDataPoint dataPoint : metric.getHistogram().getDataPointsList()) {
+                builder.addDataPoints(redactHistogramDataPoint(dataPoint));
+            }
+            metricBuilder.setHistogram(builder.build());
+        }
+        if (metric.hasExponentialHistogram()) {
+            ExponentialHistogram.Builder builder = metric.getExponentialHistogram().toBuilder().clearDataPoints();
+            for (ExponentialHistogramDataPoint dataPoint : metric.getExponentialHistogram().getDataPointsList()) {
+                builder.addDataPoints(redactExponentialHistogramDataPoint(dataPoint));
+            }
+            metricBuilder.setExponentialHistogram(builder.build());
+        }
+        if (metric.hasSummary()) {
+            Summary.Builder builder = metric.getSummary().toBuilder().clearDataPoints();
+            for (SummaryDataPoint dataPoint : metric.getSummary().getDataPointsList()) {
+                builder.addDataPoints(redactSummaryDataPoint(dataPoint));
+            }
+            metricBuilder.setSummary(builder.build());
+        }
+        return metricBuilder.build();
+    }
+
+    private NumberDataPoint redactNumberDataPoint(NumberDataPoint dataPoint) {
+        NumberDataPoint.Builder builder = dataPoint.toBuilder()
+                .clearAttributes()
+                .clearExemplars()
+                .addAllAttributes(redactAttributes(dataPoint.getAttributesList()));
+        for (Exemplar exemplar : dataPoint.getExemplarsList()) {
+            builder.addExemplars(redactExemplar(exemplar));
+        }
+        return builder.build();
+    }
+
+    private HistogramDataPoint redactHistogramDataPoint(HistogramDataPoint dataPoint) {
+        HistogramDataPoint.Builder builder = dataPoint.toBuilder()
+                .clearAttributes()
+                .clearExemplars()
+                .addAllAttributes(redactAttributes(dataPoint.getAttributesList()));
+        for (Exemplar exemplar : dataPoint.getExemplarsList()) {
+            builder.addExemplars(redactExemplar(exemplar));
+        }
+        return builder.build();
+    }
+
+    private ExponentialHistogramDataPoint redactExponentialHistogramDataPoint(
+            ExponentialHistogramDataPoint dataPoint) {
+        ExponentialHistogramDataPoint.Builder builder = dataPoint.toBuilder()
+                .clearAttributes()
+                .clearExemplars()
+                .addAllAttributes(redactAttributes(dataPoint.getAttributesList()));
+        for (Exemplar exemplar : dataPoint.getExemplarsList()) {
+            builder.addExemplars(redactExemplar(exemplar));
+        }
+        return builder.build();
+    }
+
+    private SummaryDataPoint redactSummaryDataPoint(SummaryDataPoint dataPoint) {
+        return dataPoint.toBuilder().clearAttributes()
+                .addAllAttributes(redactAttributes(dataPoint.getAttributesList()))
+                .build();
+    }
+
+    private Exemplar redactExemplar(Exemplar exemplar) {
+        if (exemplar == null) {
+            return null;
+        }
+        return exemplar.toBuilder()
+                .clearFilteredAttributes()
+                .addAllFilteredAttributes(redactAttributes(exemplar.getFilteredAttributesList()))
+                .build();
+    }
+
+    private ExportLogsServiceRequest redactLogRequest(ExportLogsServiceRequest request) {
+        if (request == null || request.getResourceLogsCount() == 0) {
+            return request;
+        }
+        ExportLogsServiceRequest.Builder requestBuilder = request.toBuilder().clearResourceLogs();
+        for (ResourceLogs resourceLogs : request.getResourceLogsList()) {
+            ResourceLogs.Builder resourceBuilder = resourceLogs.toBuilder().clearScopeLogs()
+                    .setResource(resourceLogs.getResource().toBuilder().clearAttributes()
+                            .addAllAttributes(redactAttributes(resourceLogs.getResource().getAttributesList()))
+                            .build());
+            for (ScopeLogs scopeLogs : resourceLogs.getScopeLogsList()) {
+                ScopeLogs.Builder scopeBuilder = scopeLogs.toBuilder().clearLogRecords()
+                        .setScope(redactInstrumentationScope(scopeLogs.getScope()));
+                for (LogRecord logRecord : scopeLogs.getLogRecordsList()) {
+                    scopeBuilder.addLogRecords(redactLogRecord(logRecord));
+                }
+                resourceBuilder.addScopeLogs(scopeBuilder.build());
+            }
+            requestBuilder.addResourceLogs(resourceBuilder.build());
+        }
+        return requestBuilder.build();
+    }
+
+    private LogRecord redactLogRecord(LogRecord logRecord) {
+        if (logRecord == null) {
+            return null;
+        }
+        return logRecord.toBuilder()
+                .setBody(redactAnyValue(logRecord.getBody()))
+                .clearAttributes()
+                .addAllAttributes(redactAttributes(logRecord.getAttributesList()))
+                .build();
+    }
+
+    private ExportTraceServiceRequest redactTraceRequest(ExportTraceServiceRequest request) {
+        if (request == null || request.getResourceSpansCount() == 0) {
+            return request;
+        }
+        ExportTraceServiceRequest.Builder requestBuilder = request.toBuilder().clearResourceSpans();
+        for (ResourceSpans resourceSpans : request.getResourceSpansList()) {
+            ResourceSpans.Builder resourceBuilder = resourceSpans.toBuilder().clearScopeSpans()
+                    .setResource(resourceSpans.getResource().toBuilder().clearAttributes()
+                            .addAllAttributes(redactAttributes(resourceSpans.getResource().getAttributesList()))
+                            .build());
+            for (ScopeSpans scopeSpans : resourceSpans.getScopeSpansList()) {
+                ScopeSpans.Builder scopeBuilder = scopeSpans.toBuilder().clearSpans()
+                        .setScope(redactInstrumentationScope(scopeSpans.getScope()));
+                for (Span span : scopeSpans.getSpansList()) {
+                    scopeBuilder.addSpans(redactSpan(span));
+                }
+                resourceBuilder.addScopeSpans(scopeBuilder.build());
+            }
+            requestBuilder.addResourceSpans(resourceBuilder.build());
+        }
+        return requestBuilder.build();
+    }
+
+    private Span redactSpan(Span span) {
+        if (span == null) {
+            return null;
+        }
+        Span.Builder spanBuilder = span.toBuilder()
+                .setName(redactionService.redactText(span.getName()))
+                .clearAttributes()
+                .clearEvents()
+                .clearLinks();
+        spanBuilder.addAllAttributes(redactAttributes(span.getAttributesList()));
+        if (span.hasStatus()) {
+            spanBuilder.setStatus(redactSpanStatus(span.getStatus()));
+        }
+        for (Span.Event event : span.getEventsList()) {
+            spanBuilder.addEvents(redactSpanEvent(event));
+        }
+        for (Span.Link link : span.getLinksList()) {
+            spanBuilder.addLinks(redactSpanLink(link));
+        }
+        return spanBuilder.build();
+    }
+
+    private io.opentelemetry.proto.trace.v1.Status redactSpanStatus(
+            io.opentelemetry.proto.trace.v1.Status status) {
+        if (status == null) {
+            return null;
+        }
+        return status.toBuilder()
+                .setMessage(redactionService.redactText(status.getMessage()))
+                .build();
+    }
+
+    private Span.Event redactSpanEvent(Span.Event event) {
+        if (event == null) {
+            return null;
+        }
+        return event.toBuilder()
+                .setName(redactionService.redactText(event.getName()))
+                .clearAttributes()
+                .addAllAttributes(redactAttributes(event.getAttributesList()))
+                .build();
+    }
+
+    private Span.Link redactSpanLink(Span.Link link) {
+        if (link == null) {
+            return null;
+        }
+        return link.toBuilder()
+                .clearAttributes()
+                .addAllAttributes(redactAttributes(link.getAttributesList()))
+                .build();
+    }
+
+    private InstrumentationScope redactInstrumentationScope(InstrumentationScope scope) {
+        if (scope == null || scope.getAttributesCount() == 0) {
+            return scope;
+        }
+        return scope.toBuilder().clearAttributes()
+                .addAllAttributes(redactAttributes(scope.getAttributesList()))
+                .build();
+    }
+
+    private List<KeyValue> redactAttributes(List<KeyValue> attributes) {
+        if (attributes == null || attributes.isEmpty()) {
+            return List.of();
+        }
+        List<KeyValue> redacted = new ArrayList<>(attributes.size());
+        for (KeyValue attribute : attributes) {
+            redacted.add(redactAttribute(attribute));
+        }
+        return redacted;
+    }
+
+    private KeyValue redactAttribute(KeyValue attribute) {
+        if (attribute == null || !StringUtils.isNotBlank(attribute.getKey())) {
+            return attribute;
+        }
+        if (redactionService.isSensitiveKey(attribute.getKey())) {
+            return attribute.toBuilder().setValue(redactedAnyValue()).build();
+        }
+        if (!attribute.hasValue()) {
+            return attribute;
+        }
+        return attribute.toBuilder().setValue(redactAnyValue(attribute.getValue())).build();
+    }
+
+    private AnyValue redactAnyValue(AnyValue value) {
+        if (value == null) {
+            return null;
+        }
+        return switch (value.getValueCase()) {
+            case STRING_VALUE -> value.toBuilder()
+                    .setStringValue(redactionService.redactText(value.getStringValue()))
+                    .build();
+            case ARRAY_VALUE -> value.toBuilder()
+                    .setArrayValue(redactArrayValue(value.getArrayValue()))
+                    .build();
+            case KVLIST_VALUE -> value.toBuilder()
+                    .setKvlistValue(redactKeyValueList(value.getKvlistValue()))
+                    .build();
+            default -> value;
+        };
+    }
+
+    private ArrayValue redactArrayValue(ArrayValue value) {
+        ArrayValue.Builder builder = value.toBuilder().clearValues();
+        for (AnyValue item : value.getValuesList()) {
+            builder.addValues(redactAnyValue(item));
+        }
+        return builder.build();
+    }
+
+    private KeyValueList redactKeyValueList(KeyValueList value) {
+        KeyValueList.Builder builder = value.toBuilder().clearValues();
+        for (KeyValue item : value.getValuesList()) {
+            builder.addValues(redactAttribute(item));
+        }
+        return builder.build();
+    }
+
+    private AnyValue redactedAnyValue() {
+        return AnyValue.newBuilder().setStringValue(OtlpIngestionRedactionService.REDACTED).build();
     }
 
     private MediaType resolveUpstreamContentType(MediaType contentType) {
-        if (MediaType.APPLICATION_JSON.includes(contentType)) {
-            return MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF);
+        if (contentType == null) {
+            return MEDIA_TYPE_PROTOBUF;
+        }
+        if (MediaType.APPLICATION_JSON.includes(contentType) || isExplicitProtobufMediaType(contentType)) {
+            return MEDIA_TYPE_PROTOBUF;
         }
         return contentType;
     }
 
-    private boolean acceptsJson(MediaType requestContentType, List<MediaType> acceptTypes) {
-        if (acceptTypes != null && acceptTypes.stream().anyMatch(mediaType -> mediaType.includes(MediaType.APPLICATION_JSON))) {
-            return true;
+    private List<MediaType> resolveUpstreamAcceptTypes(List<MediaType> acceptTypes) {
+        if (acceptTypes == null || acceptTypes.isEmpty()) {
+            return List.of();
         }
-        return requestContentType != null && MediaType.APPLICATION_JSON.includes(requestContentType);
+        return acceptTypes.stream()
+                .map(this::resolveUpstreamAcceptType)
+                .toList();
+    }
+
+    private MediaType resolveUpstreamAcceptType(MediaType acceptType) {
+        if (isExplicitProtobufMediaType(acceptType)) {
+            return new MediaType(MEDIA_TYPE_PROTOBUF.getType(), MEDIA_TYPE_PROTOBUF.getSubtype(),
+                    acceptType.getParameters());
+        }
+        return acceptType;
+    }
+
+    private boolean acceptsJson(MediaType requestContentType, List<MediaType> acceptTypes) {
+        return prefersJsonResponse(requestContentType, acceptTypes);
     }
 
     private byte[] normalizeResponseBodyForClient(byte[] upstreamBody, boolean traceSignal) {
         try {
-            Message response;
+            return JsonFormat.printer().print(parseSignalResponse(upstreamBody, traceSignal))
+                    .getBytes(StandardCharsets.UTF_8);
+        } catch (InvalidProtocolBufferException ex) {
+            throw io.grpc.Status.INTERNAL.withDescription(signalResponseMalformedReason(traceSignal))
+                    .withCause(ex).asRuntimeException();
+        }
+    }
+
+    private void validateSignalResponseBody(byte[] upstreamBody, boolean traceSignal) {
+        parseSignalResponse(upstreamBody, traceSignal);
+    }
+
+    private Message parseSignalResponse(byte[] upstreamBody, boolean traceSignal) {
+        byte[] safeBody = upstreamBody == null ? new byte[0] : upstreamBody;
+        try {
             if (traceSignal) {
-                response = upstreamBody.length == 0
+                return safeBody.length == 0
                         ? ExportTraceServiceResponse.getDefaultInstance()
-                        : ExportTraceServiceResponse.parseFrom(upstreamBody);
-            } else {
-                response = upstreamBody.length == 0
-                        ? ExportMetricsServiceResponse.getDefaultInstance()
-                        : ExportMetricsServiceResponse.parseFrom(upstreamBody);
+                        : ExportTraceServiceResponse.parseFrom(safeBody);
             }
-            return JsonFormat.printer().print(response).getBytes(StandardCharsets.UTF_8);
+            return safeBody.length == 0
+                    ? ExportMetricsServiceResponse.getDefaultInstance()
+                    : ExportMetricsServiceResponse.parseFrom(safeBody);
         } catch (InvalidProtocolBufferException ex) {
-            throw io.grpc.Status.INTERNAL.withDescription("OTLP backend response is malformed.")
+            throw io.grpc.Status.INTERNAL.withDescription(signalResponseMalformedReason(traceSignal))
                     .withCause(ex).asRuntimeException();
         }
     }
 
+    private String signalResponseMalformedReason(boolean traceSignal) {
+        return traceSignal ? "OTLP trace response is malformed." : "OTLP metrics response is malformed.";
+    }
+
     private void addAuthenticationHeader(HttpHeaders headers, GreptimeProperties greptimeProperties) {
-        if (StringUtils.isBlank(greptimeProperties.username()) || StringUtils.isBlank(greptimeProperties.password())) {
+        String username = StringUtils.trimToNull(greptimeProperties.username());
+        String password = StringUtils.trimToNull(greptimeProperties.password());
+        if (username == null || password == null) {
             return;
         }
-        String credentials = greptimeProperties.username() + ":" + greptimeProperties.password();
+        String credentials = username + ":" + password;
         String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
         headers.set(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials);
     }
 
-    private MediaType resolveResponseContentType(MediaType requestContentType, List<MediaType> acceptTypes) {
-        if (acceptTypes != null && acceptTypes.stream().anyMatch(mediaType -> mediaType.includes(MediaType.APPLICATION_JSON))) {
-            return MediaType.APPLICATION_JSON;
+    private String endpoint(String baseEndpoint, String path) {
+        return StringUtils.stripEnd(StringUtils.trim(baseEndpoint), "/") + path;
+    }
+
+    private StatusRuntimeException backendStatusException(HttpStatusCode statusCode) {
+        return backendStatusException(statusCode, null);
+    }
+
+    private StatusRuntimeException backendStatusException(HttpStatusCode statusCode, HttpHeaders responseHeaders) {
+        io.grpc.Status status = backendGrpcStatus(statusCode);
+        return OtlpIngestionBackpressureHeaders.statusRuntimeException(
+                status, "OTLP backend returned " + statusCode, responseHeaders);
+    }
+
+    private io.grpc.Status backendGrpcStatus(HttpStatusCode statusCode) {
+        if (statusCode == null) {
+            return io.grpc.Status.UNAVAILABLE;
+        }
+        if (statusCode.value() == HttpStatus.UNAUTHORIZED.value()) {
+            return io.grpc.Status.UNAUTHENTICATED;
+        }
+        if (statusCode.value() == HttpStatus.FORBIDDEN.value()) {
+            return io.grpc.Status.PERMISSION_DENIED;
+        }
+        if (statusCode.value() == HttpStatus.BAD_REQUEST.value()) {
+            return io.grpc.Status.INVALID_ARGUMENT;
+        }
+        if (statusCode.value() == HttpStatus.NOT_ACCEPTABLE.value()) {
+            return io.grpc.Status.INVALID_ARGUMENT;
+        }
+        if (statusCode.value() == HttpStatus.UNSUPPORTED_MEDIA_TYPE.value()) {
+            return io.grpc.Status.INVALID_ARGUMENT;
+        }
+        if (statusCode.value() == HttpStatus.UNPROCESSABLE_ENTITY.value()) {
+            return io.grpc.Status.INVALID_ARGUMENT;
         }
-        if (requestContentType != null && MediaType.APPLICATION_JSON.includes(requestContentType)) {
-            return MediaType.APPLICATION_JSON;
+        if (statusCode.value() == HttpStatus.NOT_FOUND.value()) {
+            return io.grpc.Status.NOT_FOUND;
         }
-        return MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF);
+        if (statusCode.value() == HttpStatus.CONFLICT.value()) {
+            return io.grpc.Status.ABORTED;
+        }
+        if (statusCode.value() == HttpStatus.LOCKED.value()) {
+            return io.grpc.Status.ABORTED;
+        }
+        if (statusCode.value() == HttpStatus.PRECONDITION_FAILED.value()) {
+            return io.grpc.Status.FAILED_PRECONDITION;
+        }
+        if (statusCode.value() == HttpStatus.PRECONDITION_REQUIRED.value()) {
+            return io.grpc.Status.FAILED_PRECONDITION;
+        }
+        if (statusCode.value() == HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value()) {
+            return io.grpc.Status.OUT_OF_RANGE;
+        }
+        if (statusCode.value() == HttpStatus.METHOD_NOT_ALLOWED.value()) {
+            return io.grpc.Status.UNIMPLEMENTED;
+        }
+        if (statusCode.value() == HttpStatus.NOT_IMPLEMENTED.value()) {
+            return io.grpc.Status.UNIMPLEMENTED;
+        }
+        if (statusCode.value() == HttpStatus.PAYLOAD_TOO_LARGE.value()) {
+            return io.grpc.Status.RESOURCE_EXHAUSTED;
+        }
+        if (statusCode.value() == HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE.value()) {
+            return io.grpc.Status.RESOURCE_EXHAUSTED;
+        }
+        if (statusCode.value() == HttpStatus.INSUFFICIENT_STORAGE.value()) {
+            return io.grpc.Status.RESOURCE_EXHAUSTED;
+        }
+        if (statusCode.value() == HttpStatus.TOO_MANY_REQUESTS.value()) {
+            return io.grpc.Status.RESOURCE_EXHAUSTED;
+        }
+        if (statusCode.value() == HttpStatus.TOO_EARLY.value()) {
+            return io.grpc.Status.UNAVAILABLE;
+        }
+        if (statusCode.value() == HttpStatus.REQUEST_TIMEOUT.value()
+                || statusCode.value() == HttpStatus.GATEWAY_TIMEOUT.value()) {
+            return io.grpc.Status.DEADLINE_EXCEEDED;
+        }
+        if (statusCode.value() == HttpStatus.BAD_GATEWAY.value()
+                || statusCode.value() == HttpStatus.SERVICE_UNAVAILABLE.value()) {
+            return io.grpc.Status.UNAVAILABLE;
+        }
+        if (statusCode.is4xxClientError()) {
+            return io.grpc.Status.INTERNAL;
+        }
+        if (statusCode.is5xxServerError()) {
+            return io.grpc.Status.INTERNAL;
+        }
+        return io.grpc.Status.UNAVAILABLE;
     }
 
-    private ResponseEntity<byte[]> errorResponse(MediaType contentType, HttpStatus status, String message) {
-        String errorMessage = StringUtils.defaultIfBlank(message, "OTLP signal ingestion failed.");
-        if (contentType != null && MediaType.APPLICATION_JSON.includes(contentType)) {
-            return ResponseEntity.status(status)
-                    .contentType(MediaType.APPLICATION_JSON)
-                    .body(toJsonErrorResponse(errorMessage));
+    private String database(String configuredDatabase) {
+        return StringUtils.defaultIfBlank(StringUtils.trim(configuredDatabase), DEFAULT_GREPTIME_DB_NAME);
+    }
+
+    private MediaType resolveResponseContentType(MediaType requestContentType, List<MediaType> acceptTypes) {
+        return prefersJsonResponse(requestContentType, acceptTypes)
+                ? MediaType.APPLICATION_JSON
+                : MEDIA_TYPE_PROTOBUF;
+    }
+
+    private boolean prefersJsonResponse(MediaType requestContentType, List<MediaType> acceptTypes) {
+        double jsonQuality = negotiatedQuality(acceptTypes, true);
+        double protobufQuality = negotiatedQuality(acceptTypes, false);
+        if (jsonQuality >= 0 || protobufQuality >= 0) {
+            double acceptableJsonQuality = Math.max(jsonQuality, 0.0d);
+            double acceptableProtobufQuality = Math.max(protobufQuality, 0.0d);
+            if (Double.compare(acceptableJsonQuality, acceptableProtobufQuality) == 0) {
+                return acceptableJsonQuality > 0 && isExplicitJsonMediaType(requestContentType);
+            }
+            return acceptableJsonQuality > acceptableProtobufQuality;
         }
-        return ResponseEntity.status(status)
-                .contentType(MediaType.parseMediaType(CONTENT_TYPE_PROTOBUF))
-                .body(toBinaryErrorResponse(errorMessage));
+        return isExplicitJsonMediaType(requestContentType);
     }
 
-    private byte[] toBinaryErrorResponse(String message) {
-        return Status.newBuilder().setMessage(message).build().toByteArray();
+    private double negotiatedQuality(List<MediaType> acceptTypes, boolean json) {
+        if (acceptTypes == null || acceptTypes.isEmpty()) {
+            return -1.0d;
+        }
+        double explicitQuality = acceptTypes.stream()
+                .filter(json ? this::isExplicitJsonMediaType : this::isExplicitProtobufMediaType)
+                .mapToDouble(MediaType::getQualityValue)
+                .max()
+                .orElse(-1.0d);
+        if (explicitQuality >= 0) {
+            return explicitQuality;
+        }
+        return acceptTypes.stream()
+                .filter(json ? this::isJsonWildcardMediaType : this::isProtobufWildcardMediaType)
+                .mapToDouble(MediaType::getQualityValue)
+                .max()
+                .orElse(-1.0d);
     }
 
-    private byte[] toJsonErrorResponse(String message) {
-        try {
-            return JsonFormat.printer().print(Status.newBuilder().setMessage(message).build())
-                    .getBytes(StandardCharsets.UTF_8);
-        } catch (InvalidProtocolBufferException e) {
-            return ("{\"message\":\"" + message + "\"}").getBytes(StandardCharsets.UTF_8);
+    private boolean isJsonWildcardMediaType(MediaType mediaType) {
+        return mediaType != null
+                && !isExplicitJsonMediaType(mediaType)
+                && mediaType.includes(MediaType.APPLICATION_JSON);
+    }
+
+    private boolean isProtobufWildcardMediaType(MediaType mediaType) {
+        return mediaType != null
+                && !isExplicitProtobufMediaType(mediaType)
+                && (mediaType.includes(MEDIA_TYPE_PROTOBUF) || mediaType.includes(MEDIA_TYPE_PROTOBUF_ALT));
+    }
+
+    private boolean isExplicitJsonMediaType(MediaType mediaType) {
+        if (mediaType == null || mediaType.isWildcardType() || mediaType.isWildcardSubtype()) {
+            return false;
         }
+        return "application".equalsIgnoreCase(mediaType.getType())
+                && ("json".equalsIgnoreCase(mediaType.getSubtype())
+                || mediaType.getSubtype().toLowerCase(Locale.ROOT).endsWith("+json"));
+    }
+
+    private boolean isExplicitProtobufMediaType(MediaType mediaType) {
+        if (mediaType == null || mediaType.isWildcardType() || mediaType.isWildcardSubtype()) {
+            return false;
+        }
+        return "application".equalsIgnoreCase(mediaType.getType())
+                && ("x-protobuf".equalsIgnoreCase(mediaType.getSubtype())
+                || "protobuf".equalsIgnoreCase(mediaType.getSubtype()));
     }
 
     private String defaultErrorMessage(Exception ex) {
         return StringUtils.defaultIfBlank(ex.getMessage(), "OTLP signal ingestion failed.");
     }
 
+    private long requestBytes(byte[] content) {
+        return content == null ? 0L : content.length;
+    }
+
+    private long requestBytes(Message request) {
+        return request == null ? 0L : request.getSerializedSize();
+    }
+
+    private long durationMillis(long startedAtNanos) {
+        return Math.max(0L, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos));
+    }
+
     private String normalizeOtlpJson(String content) throws InvalidProtocolBufferException {
         try {
             JsonNode root = OBJECT_MAPPER.readTree(content);
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
index d7ccdc34d4..7efc672823 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
@@ -88,6 +88,7 @@ public class OtlpIngestionWorkspaceServiceImpl implements OtlpIngestionWorkspace
     private static final String DEFAULT_METRICS_AGGREGATION = "sum";
     private static final String METRICS_CONSOLE_REF_ID = "otlp-metrics-console";
     private static final int DEFAULT_RECENT_SERVICE_LIMIT = 6;
+    private static final int DEFAULT_RECENT_UNBOUND_CANDIDATE_LIMIT = 6;
     private static final int DEFAULT_RECENT_METRIC_NAME_LIMIT = 64;
     private static final int DEFAULT_METRICS_QUERY_CANDIDATE_LIMIT = 64;
     private static final Set<String> WORKSPACE_INFRA_SERVICE_NAMES = Set.of(
@@ -585,6 +586,9 @@ public class OtlpIngestionWorkspaceServiceImpl implements OtlpIngestionWorkspace
         List<String> recentServices = collectRecentServices(identitySnapshots, DEFAULT_RECENT_SERVICE_LIMIT);
 
         Map<Long, List<EntityIdentity>> entityIdentityMap = collectRecentBoundEntityIdentities(identitySnapshots);
+        Set<String> boundIdentityMatches = boundIdentityMatchKeys(entityIdentityMap);
+        List<OtlpEntityBindingSummaryDto.UnboundEntityCandidate> unboundCandidates =
+                buildUnboundEntityCandidates(identitySnapshots, boundIdentityMatches);
 
         Map<Long, ObserveEntity> entityMap = workspaceQueryGateway.findEntitiesByIds(entityIdentityMap.keySet());
         List<OtlpEntityBindingSummaryDto.BoundEntity> boundEntities = new ArrayList<>();
@@ -618,7 +622,8 @@ public class OtlpIngestionWorkspaceServiceImpl implements OtlpIngestionWorkspace
                 EntityCanonicalIdentityRegistry.CANONICAL_OTEL_RESOURCE_KEYS,
                 recentServices,
                 samples,
-                boundEntities
+                boundEntities,
+                unboundCandidates
         );
     }
 
@@ -889,6 +894,95 @@ public class OtlpIngestionWorkspaceServiceImpl implements OtlpIngestionWorkspace
         return StringUtils.hasText(value) ? value.trim().toLowerCase(Locale.ROOT) : null;
     }
 
+    private Set<String> boundIdentityMatchKeys(Map<Long, List<EntityIdentity>> entityIdentityMap) {
+        if (CollectionUtils.isEmpty(entityIdentityMap)) {
+            return Collections.emptySet();
+        }
+        Set<String> matches = new LinkedHashSet<>();
+        for (List<EntityIdentity> identities : entityIdentityMap.values()) {
+            if (CollectionUtils.isEmpty(identities)) {
+                continue;
+            }
+            for (EntityIdentity identity : identities) {
+                if (identity == null || !StringUtils.hasText(identity.getIdentityKey())) {
+                    continue;
+                }
+                String normalizedValue = normalizeIdentityValue(
+                        defaultText(identity.getNormalizedValue(), identity.getIdentityValue())
+                );
+                if (!StringUtils.hasText(normalizedValue)) {
+                    continue;
+                }
+                matches.add(identity.getIdentityKey() + "\u0000" + normalizedValue);
+            }
+        }
+        return matches;
+    }
+
+    private List<OtlpEntityBindingSummaryDto.UnboundEntityCandidate> buildUnboundEntityCandidates(
+            List<TelemetryIdentitySnapshot> snapshots, Set<String> boundIdentityMatches) {
+        if (CollectionUtils.isEmpty(snapshots)) {
+            return Collections.emptyList();
+        }
+        Map<String, CandidateAccumulator> candidates = new LinkedHashMap<>();
+        for (TelemetryIdentitySnapshot snapshot : snapshots) {
+            if (snapshot == null || CollectionUtils.isEmpty(snapshot.getCanonicalIdentities())) {
+                continue;
+            }
+            if (matchesKnownEntityIdentity(snapshot.getCanonicalIdentities(), boundIdentityMatches)) {
+                continue;
+            }
+            String serviceName = trimToNull(firstText(
+                    snapshot.getServiceName(),
+                    snapshot.getCanonicalIdentities().get("service.name")
+            ));
+            if (!StringUtils.hasText(serviceName) || isWorkspaceNoiseService(serviceName)) {
+                continue;
+            }
+            String namespace = trimToNull(firstText(
+                    snapshot.getServiceNamespace(),
+                    snapshot.getCanonicalIdentities().get("service.namespace")
+            ));
+            String environment = trimToNull(firstText(
+                    snapshot.getEnvironmentName(),
+                    snapshot.getCanonicalIdentities().get("deployment.environment.name")
+            ));
+            String candidateKey = normalizeIdentityValue(serviceName) + "|"
+                    + defaultText(normalizeIdentityValue(namespace), "") + "|"
+                    + defaultText(normalizeIdentityValue(environment), "");
+            CandidateAccumulator accumulator = candidates.computeIfAbsent(candidateKey,
+                    ignored -> new CandidateAccumulator(serviceName, namespace, environment,
+                            snapshot.getCanonicalIdentities()));
+            accumulator.add(snapshot.getSignal(), snapshot.getObservedAt(), snapshot.getCanonicalIdentities());
+        }
+        return candidates.values().stream()
+                .sorted(Comparator.comparing(
+                        CandidateAccumulator::latestObservedAt,
+                        Comparator.nullsLast(Comparator.reverseOrder())
+                ))
+                .limit(DEFAULT_RECENT_UNBOUND_CANDIDATE_LIMIT)
+                .map(CandidateAccumulator::toDto)
+                .toList();
+    }
+
+    private boolean matchesKnownEntityIdentity(Map<String, String> canonicalIdentities, Set<String> boundIdentityMatches) {
+        if (CollectionUtils.isEmpty(canonicalIdentities) || CollectionUtils.isEmpty(boundIdentityMatches)) {
+            return false;
+        }
+        Set<String> identityKeys = canonicalIdentityKeySet();
+        for (Map.Entry<String, String> entry : canonicalIdentities.entrySet()) {
+            if (!identityKeys.contains(entry.getKey())) {
+                continue;
+            }
+            String normalizedValue = normalizeIdentityValue(entry.getValue());
+            if (StringUtils.hasText(normalizedValue)
+                    && boundIdentityMatches.contains(entry.getKey() + "\u0000" + normalizedValue)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private String truncateLogBody(Object body) {
         if (body == null) {
             return null;
@@ -1030,12 +1124,15 @@ public class OtlpIngestionWorkspaceServiceImpl implements OtlpIngestionWorkspace
         }
         String trimmed = host.trim();
         if (trimmed.startsWith("[") && trimmed.contains("]")) {
-            return trimmed;
+            return trimmed.substring(0, trimmed.indexOf(']') + 1);
         }
         int colonIndex = trimmed.lastIndexOf(':');
         if (colonIndex > 0 && trimmed.indexOf(':') == colonIndex) {
             return trimmed.substring(0, colonIndex);
         }
+        if (colonIndex > 0 && trimmed.indexOf(':') != colonIndex) {
+            return "[" + trimmed + "]";
+        }
         return trimmed;
     }
 
@@ -1623,6 +1720,65 @@ public class OtlpIngestionWorkspaceServiceImpl implements OtlpIngestionWorkspace
         return StringUtils.hasText(value) ? value.trim() : null;
     }
 
+    private final class CandidateAccumulator {
+        private final String suggestedName;
+        private final String namespace;
+        private final String environment;
+        private final LinkedHashSet<String> signals = new LinkedHashSet<>();
+        private final LinkedHashMap<String, String> canonicalIdentities = new LinkedHashMap<>();
+        private Long latestObservedAt;
+
+        private CandidateAccumulator(String suggestedName, String namespace, String environment,
+                                     Map<String, String> canonicalIdentities) {
+            this.suggestedName = suggestedName;
+            this.namespace = namespace;
+            this.environment = environment;
+            mergeCanonicalIdentities(canonicalIdentities);
+        }
+
+        private void add(String signal, Long observedAt, Map<String, String> canonicalIdentities) {
+            String normalizedSignal = trimToNull(signal);
+            if (StringUtils.hasText(normalizedSignal)) {
+                signals.add(normalizedSignal);
+            }
+            if (observedAt != null && (latestObservedAt == null || observedAt > latestObservedAt)) {
+                latestObservedAt = observedAt;
+            }
+            mergeCanonicalIdentities(canonicalIdentities);
+        }
+
+        private Long latestObservedAt() {
+            return latestObservedAt;
+        }
+
+        private OtlpEntityBindingSummaryDto.UnboundEntityCandidate toDto() {
+            String primaryIdentityValue = firstText(canonicalIdentities.get("service.name"), suggestedName);
+            return new OtlpEntityBindingSummaryDto.UnboundEntityCandidate(
+                    suggestedName,
+                    "service",
+                    namespace,
+                    environment,
+                    "service.name",
+                    primaryIdentityValue,
+                    List.copyOf(signals),
+                    new LinkedHashMap<>(canonicalIdentities),
+                    latestObservedAt
+            );
+        }
+
+        private void mergeCanonicalIdentities(Map<String, String> identities) {
+            if (CollectionUtils.isEmpty(identities)) {
+                return;
+            }
+            for (String key : EntityCanonicalIdentityRegistry.CANONICAL_OTEL_RESOURCE_KEYS) {
+                String value = trimToNull(identities.get(key));
+                if (StringUtils.hasText(value)) {
+                    canonicalIdentities.putIfAbsent(key, value);
+                }
+            }
+        }
+    }
+
     private record MetricsQueryExecution(String datasource,
                                          DatasourceQueryData results,
                                          OtlpMetricsConsoleDto.Stats stats,
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/service/impl/LogQueryServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/service/impl/LogQueryServiceImpl.java
index 6cf24800fe..2f8daf53ff 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/service/impl/LogQueryServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/service/impl/LogQueryServiceImpl.java
@@ -31,6 +31,9 @@ import java.util.Set;
 import java.util.stream.Collectors;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.common.entity.log.LogEntry;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
+import org.apache.hertzbeat.observability.ingestion.enricher.OtlpCorrelationEnricher;
 import org.apache.hertzbeat.observability.logs.service.LogQueryService;
 import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -66,6 +69,11 @@ public class LogQueryServiceImpl implements LogQueryService {
             "flagd",
             "flagd-ui"
     );
+    private static final Set<String> WORKSPACE_RESOURCE_KEYS = Set.of(
+            OtlpCorrelationEnricher.WORKSPACE_ID_ATTRIBUTE,
+            AuthTokenScopes.CLAIM_WORKSPACE_ID,
+            "workspace.id"
+    );
 
     private final List<HistoryDataReader> historyDataReaders;
 
@@ -184,6 +192,9 @@ public class LogQueryServiceImpl implements LogQueryService {
     private Map<String, Long> readSeverityBuckets(Long start, Long end, String traceId, String spanId,
                                                   Integer severityNumber, String severityText, String search,
                                                   boolean hideInternal, boolean hideNoise) {
+        if (hasWorkspaceContext()) {
+            return null;
+        }
         for (HistoryDataReader historyDataReader : historyDataReaders) {
             try {
                 Map<String, Long> aggregate = historyDataReader.countLogsBySeverityBuckets(
@@ -202,6 +213,9 @@ public class LogQueryServiceImpl implements LogQueryService {
     private Map<String, Long> readTraceCoverage(Long start, Long end, String traceId, String spanId,
                                                 Integer severityNumber, String severityText, String search,
                                                 boolean hideInternal, boolean hideNoise) {
+        if (hasWorkspaceContext()) {
+            return null;
+        }
         for (HistoryDataReader historyDataReader : historyDataReaders) {
             try {
                 Map<String, Long> aggregate = historyDataReader.countLogTraceCoverage(
@@ -220,6 +234,9 @@ public class LogQueryServiceImpl implements LogQueryService {
     private Map<String, Long> readHourlyStats(Long start, Long end, String traceId, String spanId,
                                               Integer severityNumber, String severityText, String search,
                                               boolean hideInternal, boolean hideNoise) {
+        if (hasWorkspaceContext()) {
+            return null;
+        }
         for (HistoryDataReader historyDataReader : historyDataReaders) {
             try {
                 Map<String, Long> aggregate = historyDataReader.countLogsByHour(
@@ -250,7 +267,7 @@ public class LogQueryServiceImpl implements LogQueryService {
                             start, end, traceId, spanId, severityNumber, severityText, search);
                 }
                 if (logs != null && !logs.isEmpty()) {
-                    return filterWorkspaceLogs(logs, hideInternal, hideNoise);
+                    return filterQueryLogs(logs, hideInternal, hideNoise);
                 }
             } catch (UnsupportedOperationException ex) {
                 // Try the next reader. Not every history store supports log queries.
@@ -266,6 +283,11 @@ public class LogQueryServiceImpl implements LogQueryService {
         Sort sort = Sort.by(Sort.Direction.DESC, "timeUnixNano");
         PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sort);
 
+        if (hasWorkspaceContext()) {
+            return getWorkspacePagedLogs(start, end, traceId, spanId, severityNumber, severityText, search,
+                    pageRequest, offset, pageSize, hideInternal, hideNoise);
+        }
+
         for (HistoryDataReader historyDataReader : historyDataReaders) {
             try {
                 if (hideInternal || hideNoise) {
@@ -280,7 +302,7 @@ public class LogQueryServiceImpl implements LogQueryService {
                     List<LogEntry> pagedLogs = historyDataReader.queryLogsByMultipleConditionsWithPagination(
                             start, end, traceId, spanId, severityNumber, severityText, search, offset, pageSize,
                             hiddenServiceNames, requireServiceName);
-                    List<LogEntry> filteredLogs = filterWorkspaceLogs(pagedLogs, hideInternal, hideNoise);
+                    List<LogEntry> filteredLogs = filterQueryLogs(pagedLogs, hideInternal, hideNoise);
                     long safeTotal = filteredLogs.size() < (pagedLogs == null ? 0 : pagedLogs.size())
                             ? offset + filteredLogs.size()
                             : totalElements;
@@ -293,7 +315,41 @@ public class LogQueryServiceImpl implements LogQueryService {
                 }
                 List<LogEntry> pagedLogs = historyDataReader.queryLogsByMultipleConditionsWithPagination(
                         start, end, traceId, spanId, severityNumber, severityText, search, offset, pageSize);
-                return new PageImpl<>(pagedLogs == null ? Collections.emptyList() : pagedLogs, pageRequest, totalElements);
+                List<LogEntry> filteredLogs = filterQueryLogs(pagedLogs, hideInternal, hideNoise);
+                long safeTotal = filteredLogs.size() < (pagedLogs == null ? 0 : pagedLogs.size())
+                        ? offset + filteredLogs.size()
+                        : totalElements;
+                return new PageImpl<>(filteredLogs, pageRequest, safeTotal);
+            } catch (UnsupportedOperationException ex) {
+                // Try the next reader. Not every history store supports log queries.
+            }
+        }
+        return new PageImpl<>(Collections.emptyList(), pageRequest, 0);
+    }
+
+    private Page<LogEntry> getWorkspacePagedLogs(Long start, Long end, String traceId, String spanId,
+                                                 Integer severityNumber, String severityText, String search,
+                                                 PageRequest pageRequest, int offset, int pageSize,
+                                                 boolean hideInternal, boolean hideNoise) {
+        for (HistoryDataReader historyDataReader : historyDataReaders) {
+            try {
+                List<LogEntry> logs;
+                if (hideInternal || hideNoise) {
+                    logs = historyDataReader.queryLogsByMultipleConditions(
+                            start, end, traceId, spanId, severityNumber, severityText, search,
+                            hiddenServiceNames(hideInternal, hideNoise), shouldRequireServiceName(hideInternal, hideNoise));
+                } else {
+                    logs = historyDataReader.queryLogsByMultipleConditions(
+                            start, end, traceId, spanId, severityNumber, severityText, search);
+                }
+                if (logs == null || logs.isEmpty()) {
+                    continue;
+                }
+                List<LogEntry> filteredLogs = filterQueryLogs(logs, hideInternal, hideNoise);
+                int fromIndex = Math.min(offset, filteredLogs.size());
+                int toIndex = Math.min(fromIndex + pageSize, filteredLogs.size());
+                return new PageImpl<>(List.copyOf(filteredLogs.subList(fromIndex, toIndex)),
+                        pageRequest, filteredLogs.size());
             } catch (UnsupportedOperationException ex) {
                 // Try the next reader. Not every history store supports log queries.
             }
@@ -319,12 +375,20 @@ public class LogQueryServiceImpl implements LogQueryService {
         return hideInternal || hideNoise;
     }
 
-    private List<LogEntry> filterWorkspaceLogs(List<LogEntry> logs, boolean hideInternal, boolean hideNoise) {
-        if ((!hideInternal && !hideNoise) || logs == null || logs.isEmpty()) {
+    private List<LogEntry> filterQueryLogs(List<LogEntry> logs, boolean hideInternal, boolean hideNoise) {
+        if (logs == null || logs.isEmpty()) {
             return logs == null ? Collections.emptyList() : logs;
         }
+        String workspaceId = AuthTokenRequestContext.currentWorkspaceId();
+        if (!StringUtils.hasText(workspaceId) && !hideInternal && !hideNoise) {
+            return logs;
+        }
+        String normalizedWorkspaceId = StringUtils.hasText(workspaceId)
+                ? AuthTokenScopes.normalizeWorkspaceId(workspaceId)
+                : null;
         return logs.stream()
                 .filter(log -> !shouldHideWorkspaceLog(log, hideInternal, hideNoise))
+                .filter(log -> matchesWorkspace(log, normalizedWorkspaceId))
                 .toList();
     }
 
@@ -339,6 +403,34 @@ public class LogQueryServiceImpl implements LogQueryService {
         return hideNoise && DEMO_INFRA_SERVICE_NAMES.contains(serviceName);
     }
 
+    private boolean hasWorkspaceContext() {
+        return StringUtils.hasText(AuthTokenRequestContext.currentWorkspaceId());
+    }
+
+    private boolean matchesWorkspace(LogEntry logEntry, String workspaceId) {
+        if (!StringUtils.hasText(workspaceId)) {
+            return true;
+        }
+        String logWorkspaceId = resolveWorkspaceId(logEntry);
+        if (!StringUtils.hasText(logWorkspaceId)) {
+            return AuthTokenScopes.DEFAULT_WORKSPACE_ID.equals(workspaceId);
+        }
+        return workspaceId.equals(AuthTokenScopes.normalizeWorkspaceId(logWorkspaceId));
+    }
+
+    private String resolveWorkspaceId(LogEntry logEntry) {
+        if (logEntry == null || logEntry.getResource() == null || logEntry.getResource().isEmpty()) {
+            return null;
+        }
+        for (String key : WORKSPACE_RESOURCE_KEYS) {
+            String value = normalizeRawValue(logEntry.getResource().get(key));
+            if (StringUtils.hasText(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
     private String resolveServiceName(LogEntry logEntry) {
         if (logEntry == null || logEntry.getResource() == null || logEntry.getResource().isEmpty()) {
             return null;
@@ -350,6 +442,14 @@ public class LogQueryServiceImpl implements LogQueryService {
         return serviceName;
     }
 
+    private String normalizeRawValue(Object rawValue) {
+        if (rawValue == null) {
+            return null;
+        }
+        String normalized = String.valueOf(rawValue).trim();
+        return normalized.isEmpty() ? null : normalized;
+    }
+
     private String normalizeResourceValue(Object rawValue) {
         if (rawValue == null) {
             return null;
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/service/impl/LogSseServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/service/impl/LogSseServiceImpl.java
index 4d4f96aee7..f233e2fa16 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/service/impl/LogSseServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/service/impl/LogSseServiceImpl.java
@@ -17,11 +17,14 @@
 
 package org.apache.hertzbeat.observability.logs.service.impl;
 
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator;
 import org.apache.hertzbeat.observability.logs.service.LogSseService;
 import org.apache.hertzbeat.observability.logs.sse.LogSseFilterCriteria;
 import org.apache.hertzbeat.observability.logs.sse.LogSseManager;
 import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 /**
@@ -39,6 +42,14 @@ public class LogSseServiceImpl implements LogSseService {
     @Override
     public SseEmitter subscribe(LogSseFilterCriteria filterCriteria) {
         Long clientId = SnowFlakeIdGenerator.generateId();
+        bindRequestWorkspace(filterCriteria);
         return emitterManager.createEmitter(clientId, filterCriteria);
     }
+
+    private void bindRequestWorkspace(LogSseFilterCriteria filterCriteria) {
+        String workspaceId = AuthTokenRequestContext.currentWorkspaceId();
+        if (filterCriteria != null && StringUtils.hasText(workspaceId)) {
+            filterCriteria.setWorkspaceId(AuthTokenScopes.normalizeWorkspaceId(workspaceId));
+        }
+    }
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/sse/LogSseFilterCriteria.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/sse/LogSseFilterCriteria.java
index 99d4bc5e38..d9052dfe69 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/sse/LogSseFilterCriteria.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/logs/sse/LogSseFilterCriteria.java
@@ -19,13 +19,17 @@
 
 package org.apache.hertzbeat.observability.logs.sse;
 
+import java.util.Map;
+import java.util.Set;
 import lombok.Data;
 import lombok.AllArgsConstructor;
 import lombok.NoArgsConstructor;
 import org.apache.hertzbeat.common.entity.log.LogEntry;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.springframework.util.StringUtils;
 import io.swagger.v3.oas.annotations.media.Schema;
 import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE;
+import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
 
 /**
  * Log filtering criteria for SSE (Server-Sent Events) log streaming
@@ -35,6 +39,13 @@ import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE;
 @NoArgsConstructor
 @Schema(description = "Log filtering criteria for SSE (Server-Sent Events) log streaming")
 public class LogSseFilterCriteria {
+
+    private static final Set<String> WORKSPACE_RESOURCE_KEYS = Set.of(
+            "hertzbeat.workspace_id",
+            AuthTokenScopes.CLAIM_WORKSPACE_ID,
+            "workspace.id"
+    );
+
     /**
      * Numerical value of the severity.
      * Smaller numerical values correspond to less severe events (such as debug events),
@@ -71,6 +82,20 @@ public class LogSseFilterCriteria {
     @Schema(description = "A unique identifier for a span.", example = "1234567890", accessMode = READ_WRITE)
     private String spanId;
 
+    /**
+     * Workspace boundary captured from the authenticated request.
+     */
+    @Schema(description = "Server-bound workspace boundary.", accessMode = READ_ONLY)
+    private String workspaceId;
+
+    public LogSseFilterCriteria(Integer severityNumber, String severityText, String logContent, String traceId,
+                                String spanId) {
+        this.severityNumber = severityNumber;
+        this.severityText = severityText;
+        this.logContent = logContent;
+        this.traceId = traceId;
+        this.spanId = spanId;
+    }
 
     /**
      * Core filtering logic to determine if a log entry matches the criteria
@@ -79,6 +104,9 @@ public class LogSseFilterCriteria {
      */
     public boolean matches(LogEntry log) {
         if (log == null) return false;
+        if (!matchesWorkspace(log)) {
+            return false;
+        }
         // Check severity text match
         if (StringUtils.hasText(severityText) && !severityText.equalsIgnoreCase(log.getSeverityText())) {
             return false;
@@ -113,4 +141,29 @@ public class LogSseFilterCriteria {
         }
         return true;
     }
+
+    private boolean matchesWorkspace(LogEntry log) {
+        if (!StringUtils.hasText(workspaceId)) {
+            return true;
+        }
+        String normalizedWorkspaceId = AuthTokenScopes.normalizeWorkspaceId(workspaceId);
+        String logWorkspaceId = resolveWorkspaceId(log.getResource());
+        if (!StringUtils.hasText(logWorkspaceId)) {
+            return AuthTokenScopes.DEFAULT_WORKSPACE_ID.equals(normalizedWorkspaceId);
+        }
+        return normalizedWorkspaceId.equals(AuthTokenScopes.normalizeWorkspaceId(logWorkspaceId));
+    }
+
+    private String resolveWorkspaceId(Map<String, Object> resource) {
+        if (resource == null || resource.isEmpty()) {
+            return null;
+        }
+        for (String key : WORKSPACE_RESOURCE_KEYS) {
+            Object value = resource.get(key);
+            if (value != null && StringUtils.hasText(String.valueOf(value))) {
+                return String.valueOf(value);
+            }
+        }
+        return null;
+    }
 }
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImpl.java
index 586294fedd..379a75fba6 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImpl.java
@@ -56,6 +56,7 @@ import org.apache.hertzbeat.common.observability.dto.trace.TraceListItemDto;
 import org.apache.hertzbeat.common.observability.model.CodeNavigationHint;
 import org.apache.hertzbeat.common.observability.model.EntityCanonicalIdentityRegistry;
 import org.apache.hertzbeat.common.observability.model.ObservedEntityContext;
+import org.apache.hertzbeat.observability.ingestion.semantic.OtlpResourceSemanticAttributes;
 import org.apache.hertzbeat.warehouse.repository.LogQueryRepository;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.event.EventListener;
@@ -93,6 +94,8 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
     private static final String OTLP_METRIC_SUMMARY_QUANTILES = "otlp.metric.summary.quantiles";
     private static final String OTLP_METRIC_HISTOGRAM_BUCKET_COUNTS = "otlp.metric.histogram.bucket_counts";
     private static final String OTLP_METRIC_HISTOGRAM_EXPLICIT_BOUNDS = "otlp.metric.histogram.explicit_bounds";
+    private static final String HERTZBEAT_ENTITY_ID = OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID;
+    private static final String HERTZBEAT_WORKSPACE_ID = OtlpResourceSemanticAttributes.HERTZBEAT_WORKSPACE_ID;
     private static final Set<String> WORKSPACE_INFRA_SERVICE_NAMES = Set.of(
             "otelcol-contrib",
             "otel-collector",
@@ -604,22 +607,17 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
     @Override
     public List<LogEvidence> buildLogEvidence(ObservedEntityContext entityContext, EntityLogSummaryInfo logSummary,
                                               List<EntityLogQueryHint> logQueryHints) {
-        if (entityContext == null || CollectionUtils.isEmpty(entityContext.getIdentities())) {
+        long entityId = entityId(entityContext);
+        if (entityContext == null || entityId == 0L) {
             return Collections.emptyList();
         }
-        long entityId = entityContext == null || entityContext.getEntity() == null || entityContext.getEntity().getId() == null
-                ? 0L
-                : entityContext.getEntity().getId();
         Set<String> entityIdentityKeys = entityIdentityKeySet(entityContext);
         Map<String, String> entityIdentityValues = entityIdentityValueMap(entityContext);
-        if (entityIdentityKeys.isEmpty()) {
-            return Collections.emptyList();
-        }
         EntityLogQueryHint preferredHint = CollectionUtils.isEmpty(logQueryHints) ? null : logQueryHints.getFirst();
         TelemetryBindingResult bindingResult = buildBindingResult(entityContext, SOURCE_OTLP, SIGNAL_LOGS);
         List<LogEvidence> results = new ArrayList<>();
         for (RecentLogSignal signal : recentLogSignals) {
-            if (!matchesEntityIdentities(signal.canonicalIdentities(), entityIdentityKeys, entityIdentityValues)) {
+            if (!matchesEntitySignal(signal.canonicalIdentities(), entityIdentityKeys, entityIdentityValues, entityId)) {
                 continue;
             }
             TelemetryIdentitySnapshot identitySnapshot = new TelemetryIdentitySnapshot(
@@ -727,7 +725,7 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
         List<LogEvidence> results = new ArrayList<>();
         for (LogEntry log : storedLogs) {
             Map<String, String> canonicalIdentities = extractCanonicalStringMap(log == null ? null : log.getResource());
-            if (!matchesEntityIdentities(canonicalIdentities, entityIdentityKeys, entityIdentityValues)) {
+            if (!matchesEntitySignal(canonicalIdentities, entityIdentityKeys, entityIdentityValues, entityId)) {
                 continue;
             }
             Map<String, String> resource = extractStringMap(log == null ? null : log.getResource());
@@ -773,20 +771,18 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
 
     @Override
     public EntityTraceSummaryDto buildTraceSummary(ObservedEntityContext entityContext) {
-        if (entityContext == null || CollectionUtils.isEmpty(entityContext.getIdentities())) {
+        long entityId = entityId(entityContext);
+        if (entityContext == null || entityId == 0L) {
             return new EntityTraceSummaryDto(0, 0, null, false, null);
         }
         Set<String> entityIdentityKeys = entityIdentityKeySet(entityContext);
         Map<String, String> entityIdentityValues = entityIdentityValueMap(entityContext);
-        if (entityIdentityKeys.isEmpty()) {
-            return new EntityTraceSummaryDto(0, 0, null, false, null);
-        }
         int recentTraceCount = 0;
         int recentErrorTraceCount = 0;
         Long latestObservedAt = null;
         String latestTraceId = null;
         for (RecentTraceSignal signal : recentTraceSignals) {
-            if (!matchesEntityIdentities(signal.canonicalIdentities(), entityIdentityKeys, entityIdentityValues)) {
+            if (!matchesEntitySignal(signal.canonicalIdentities(), entityIdentityKeys, entityIdentityValues, entityId)) {
                 continue;
             }
             recentTraceCount++;
@@ -811,17 +807,15 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
 
     @Override
     public List<EntityTraceQueryHintDto> buildTraceQueryHints(ObservedEntityContext entityContext) {
-        if (entityContext == null || CollectionUtils.isEmpty(entityContext.getIdentities())) {
+        long entityId = entityId(entityContext);
+        if (entityContext == null || entityId == 0L) {
             return Collections.emptyList();
         }
         Set<String> entityIdentityKeys = entityIdentityKeySet(entityContext);
         Map<String, String> entityIdentityValues = entityIdentityValueMap(entityContext);
-        if (entityIdentityKeys.isEmpty()) {
-            return Collections.emptyList();
-        }
         RecentTraceSignal preferredSignal = null;
         for (RecentTraceSignal signal : recentTraceSignals) {
-            if (!matchesEntityIdentities(signal.canonicalIdentities(), entityIdentityKeys, entityIdentityValues)) {
+            if (!matchesEntitySignal(signal.canonicalIdentities(), entityIdentityKeys, entityIdentityValues, entityId)) {
                 continue;
             }
             if (preferredSignal == null
@@ -936,7 +930,8 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
                 logsActive,
                 tracesActive,
                 metricEvidence == null ? 0 : metricEvidence.size(),
-                logSummary == null ? 0 : logSummary.getHintCount(),
+                Math.max(logSummary == null ? 0 : logSummary.getHintCount(),
+                        logEvidence == null ? 0 : logEvidence.size()),
                 traceSummary == null ? 0 : traceSummary.getRecentTraceCount(),
                 latestObservedAt,
                 activeSignals
@@ -1091,6 +1086,16 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
                         .ifPresent(value -> canonicalIdentities.put(key, value));
             }
         }
+        long entityId = entityId(entityContext);
+        if (entityId > 0) {
+            canonicalIdentities.putIfAbsent(HERTZBEAT_ENTITY_ID, String.valueOf(entityId));
+        }
+        String workspaceId = entityContext == null || entityContext.getEntity() == null
+                ? null
+                : trimToNull(entityContext.getEntity().getWorkspaceId());
+        if (workspaceId != null) {
+            canonicalIdentities.putIfAbsent(HERTZBEAT_WORKSPACE_ID, workspaceId);
+        }
         return new TelemetryIdentitySnapshot(
                 source,
                 signal,
@@ -1141,7 +1146,7 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
         }
         Map<String, String> canonical = new LinkedHashMap<>();
         for (String key : EntityCanonicalIdentityRegistry.CANONICAL_OTEL_RESOURCE_KEYS) {
-            Object value = values.get(key);
+            Object value = canonicalResourceValue(values, key);
             if (value == null) {
                 continue;
             }
@@ -1150,21 +1155,43 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
                 canonical.put(key, normalized);
             }
         }
+        putCanonicalResourceValue(canonical, values, HERTZBEAT_ENTITY_ID);
+        putCanonicalResourceValue(canonical, values, HERTZBEAT_WORKSPACE_ID);
         return canonical;
     }
 
+    private void putCanonicalResourceValue(Map<String, String> canonical, Map<?, ?> values, String key) {
+        Object value = canonicalResourceValue(values, key);
+        if (value == null) {
+            return;
+        }
+        String normalized = trimToNull(String.valueOf(value));
+        if (normalized != null) {
+            canonical.put(key, normalized);
+        }
+    }
+
+    private Object canonicalResourceValue(Map<?, ?> values, String key) {
+        Object value = values.get(key);
+        if (value != null) {
+            return value;
+        }
+        return values.get(normalizeOtelResourceStorageKey(key));
+    }
+
+    private String normalizeOtelResourceStorageKey(String key) {
+        return key == null ? null : key.replace(".", "_");
+    }
+
     private List<MetricEvidence> buildOtlpMetricEvidence(ObservedEntityContext entityContext, long entityId) {
-        if (entityContext == null || CollectionUtils.isEmpty(entityContext.getIdentities()) || recentMetricSignals.isEmpty()) {
+        if (entityContext == null || entityId == 0L || recentMetricSignals.isEmpty()) {
             return Collections.emptyList();
         }
         List<MetricEvidence> result = new ArrayList<>();
         Set<String> entityIdentityKeys = entityIdentityKeySet(entityContext);
         Map<String, String> entityIdentityValues = entityIdentityValueMap(entityContext);
-        if (entityIdentityKeys.isEmpty()) {
-            return Collections.emptyList();
-        }
         for (RecentMetricSignal signal : recentMetricSignals) {
-            if (!matchesEntityIdentities(signal.canonicalIdentities(), entityIdentityKeys, entityIdentityValues)) {
+            if (!matchesEntitySignal(signal.canonicalIdentities(), entityIdentityKeys, entityIdentityValues, entityId)) {
                 continue;
             }
             TelemetryIdentitySnapshot snapshot = new TelemetryIdentitySnapshot(
@@ -1265,6 +1292,23 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
         return String.join(";", segments);
     }
 
+    private boolean matchesEntitySignal(Map<String, String> canonicalIdentities,
+                                        Set<String> entityIdentityKeys,
+                                        Map<String, String> entityIdentityValues,
+                                        long entityId) {
+        if (matchesExplicitEntityId(canonicalIdentities, entityId)) {
+            return true;
+        }
+        return matchesEntityIdentities(canonicalIdentities, entityIdentityKeys, entityIdentityValues);
+    }
+
+    private boolean matchesExplicitEntityId(Map<String, String> canonicalIdentities, long entityId) {
+        if (entityId <= 0 || CollectionUtils.isEmpty(canonicalIdentities)) {
+            return false;
+        }
+        return Objects.equals(String.valueOf(entityId), trimToNull(canonicalIdentities.get(HERTZBEAT_ENTITY_ID)));
+    }
+
     private boolean matchesEntityIdentities(Map<String, String> canonicalIdentities,
                                             Set<String> entityIdentityKeys,
                                             Map<String, String> entityIdentityValues) {
@@ -1299,6 +1343,12 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
         return matched;
     }
 
+    private long entityId(ObservedEntityContext entityContext) {
+        return entityContext == null || entityContext.getEntity() == null || entityContext.getEntity().getId() == null
+                ? 0L
+                : entityContext.getEntity().getId();
+    }
+
     private Set<String> entityIdentityKeySet(ObservedEntityContext entityContext) {
         if (entityContext == null || CollectionUtils.isEmpty(entityContext.getIdentities())) {
             return Collections.emptySet();
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
index ad3b20d56b..8b23e9b6af 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
@@ -17,11 +17,15 @@
 
 package org.apache.hertzbeat.observability.traces.service.impl;
 
+import java.math.BigDecimal;
+import java.math.BigInteger;
 import java.sql.Timestamp;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
 import java.time.ZoneId;
+import java.time.ZonedDateTime;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -37,6 +41,8 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
 import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
+import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.apache.hertzbeat.common.observability.gateway.ObservabilityWorkspaceQueryGateway;
 import org.apache.hertzbeat.common.observability.model.EntityCanonicalIdentityRegistry;
 import org.apache.hertzbeat.common.observability.model.ObservedEntityContext;
@@ -48,6 +54,7 @@ import org.apache.hertzbeat.common.observability.dto.trace.TraceOverviewDto;
 import org.apache.hertzbeat.common.observability.dto.trace.TraceSpanEventDto;
 import org.apache.hertzbeat.common.observability.dto.trace.TraceSpanLinkDto;
 import org.apache.hertzbeat.common.observability.dto.trace.TraceSpanNodeDto;
+import org.apache.hertzbeat.observability.ingestion.enricher.OtlpCorrelationEnricher;
 import org.apache.hertzbeat.observability.traces.service.EntityTraceQueryService;
 import org.apache.hertzbeat.warehouse.repository.TraceQueryRepository;
 import org.springframework.data.domain.Page;
@@ -73,6 +80,15 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
     private static final int TRACE_DETAIL_LIMIT = 5000;
     private static final long DEFAULT_LOOKBACK_MILLIS = Duration.ofHours(24).toMillis();
     private static final long ACTIVE_TRACE_WINDOW_MILLIS = Duration.ofMinutes(15).toMillis();
+    private static final BigInteger LONG_MAX_VALUE = BigInteger.valueOf(Long.MAX_VALUE);
+    private static final BigInteger LONG_MIN_VALUE = BigInteger.valueOf(Long.MIN_VALUE);
+    private static final BigDecimal LONG_MAX_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);
+    private static final BigDecimal LONG_MIN_DECIMAL = BigDecimal.valueOf(Long.MIN_VALUE);
+    private static final Set<String> WORKSPACE_RESOURCE_KEYS = Set.of(
+            OtlpCorrelationEnricher.WORKSPACE_ID_ATTRIBUTE,
+            AuthTokenScopes.CLAIM_WORKSPACE_ID,
+            "workspace.id"
+    );
 
     private final TraceQueryRepository traceQueryRepository;
     private final ObservabilityWorkspaceQueryGateway workspaceQueryGateway;
@@ -84,7 +100,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
             return new EntityTraceSummaryDto(0, 0, null, false, null);
         }
         long now = System.currentTimeMillis();
-        List<TraceAggregate> traces = aggregateTraceRows(queryRecentRows())
+        List<TraceAggregate> traces = aggregateTraceRows(queryRecentRows(identityValues, now))
                 .stream()
                 .filter(trace -> matchesEntity(trace, identityValues))
                 .filter(trace -> trace.getStartTime() == null || trace.getStartTime() >= now - DEFAULT_LOOKBACK_MILLIS)
@@ -151,12 +167,37 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
                                                  String serviceName, String serviceNamespace, String environment,
                                                  int pageIndex, int pageSize, Boolean hideInternal) {
         Map<String, Set<String>> identityValues = entityId == null ? Collections.emptyMap() : canonicalIdentityValues(loadEntityContext(entityId));
-        List<TraceAggregate> filtered = aggregateTraceRows(queryRowsForList(traceId, serviceName, hideInternal)).stream()
+        PageRequest pageRequest = PageRequest.of(Math.max(pageIndex, 0), Math.max(pageSize, 1));
+        if (!StringUtils.hasText(traceId) && traceQueryRepository.supportsTraceListRows()) {
+            List<Map<String, Object>> rows = traceQueryRepository.queryTraceListRows(
+                    start,
+                    end,
+                    errorOnly,
+                    serviceName,
+                    serviceNamespace,
+                    environment,
+                    AuthTokenRequestContext.currentWorkspaceId(),
+                    identityValues,
+                    hideInternal,
+                    Math.toIntExact(pageRequest.getOffset()),
+                    pageRequest.getPageSize()
+            );
+            List<TraceListItemDto> items = rows.stream()
+                    .map(this::toTraceListItem)
+                    .toList();
+            long total = rows.stream()
+                    .map(row -> readLongValue(row, "total_count", "totalCount"))
+                    .filter(Objects::nonNull)
+                    .findFirst()
+                    .orElse((long) pageRequest.getOffset() + items.size());
+            return new PageImpl<>(items, pageRequest, total);
+        }
+        List<TraceAggregate> filtered = aggregateTraceRows(queryRowsForList(traceId, start, end, serviceName,
+                serviceNamespace, environment, identityValues, hideInternal)).stream()
                 .filter(trace -> matchesTraceFilters(trace, identityValues, start, end, traceId, errorOnly,
                         serviceName, serviceNamespace, environment, hideInternal))
                 .sorted(Comparator.comparing(TraceAggregate::getStartTime, Comparator.nullsLast(Comparator.reverseOrder())))
                 .toList();
-        PageRequest pageRequest = PageRequest.of(Math.max(pageIndex, 0), Math.max(pageSize, 1));
         int safeStart = Math.min((int) pageRequest.getOffset(), filtered.size());
         int safeEnd = Math.min(safeStart + pageRequest.getPageSize(), filtered.size());
         List<TraceListItemDto> items = filtered.subList(safeStart, safeEnd).stream()
@@ -171,7 +212,8 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
             return null;
         }
         Map<String, Set<String>> identityValues = entityId == null ? Collections.emptyMap() : canonicalIdentityValues(loadEntityContext(entityId));
-        TraceAggregate aggregate = aggregateTraceRows(queryTraceRows(traceId)).stream()
+        TraceAggregate aggregate = aggregateTraceRows(queryTraceRows(traceId, null, null, null, null, null,
+                identityValues, false)).stream()
                 .filter(trace -> identityValues.isEmpty() || matchesEntity(trace, identityValues))
                 .findFirst()
                 .orElse(null);
@@ -214,15 +256,95 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
         return traceQueryRepository.queryRecentTraceRows(TRACE_LIST_SAMPLE_LIMIT);
     }
 
-    private List<Map<String, Object>> queryRowsForList(String traceId, String serviceName, Boolean hideInternal) {
+    private List<Map<String, Object>> queryRecentRows(Map<String, Set<String>> identityValues, long now) {
+        if (CollectionUtils.isEmpty(identityValues)) {
+            return queryRecentRows();
+        }
+        return traceQueryRepository.queryRecentTraceRows(
+                TRACE_LIST_SAMPLE_LIMIT,
+                Math.max(0L, now - DEFAULT_LOOKBACK_MILLIS),
+                now,
+                preferredIdentityValue(identityValues, "service.name"),
+                preferredIdentityValue(identityValues, "service.namespace"),
+                preferredIdentityValue(identityValues, "deployment.environment.name"),
+                AuthTokenRequestContext.currentWorkspaceId(),
+                identityValues,
+                false
+        );
+    }
+
+    private List<Map<String, Object>> queryRowsForList(String traceId,
+                                                       Long start,
+                                                       Long end,
+                                                       String serviceName,
+                                                       String serviceNamespace,
+                                                       String environment,
+                                                       Map<String, Set<String>> identityValues,
+                                                       Boolean hideInternal) {
         if (StringUtils.hasText(traceId)) {
-            return queryTraceRows(traceId);
+            return queryTraceRows(traceId, start, end, serviceName, serviceNamespace, environment,
+                    identityValues, hideInternal);
         }
-        return traceQueryRepository.queryRecentTraceRows(TRACE_LIST_SAMPLE_LIMIT, serviceName, hideInternal);
+        return traceQueryRepository.queryRecentTraceRows(
+                TRACE_LIST_SAMPLE_LIMIT,
+                start,
+                end,
+                serviceName,
+                serviceNamespace,
+                environment,
+                AuthTokenRequestContext.currentWorkspaceId(),
+                identityValues,
+                hideInternal
+        );
     }
 
     private List<Map<String, Object>> queryTraceRows(String traceId) {
-        return traceQueryRepository.queryTraceRows(traceId, TRACE_DETAIL_LIMIT);
+        return queryTraceRows(traceId, null, null, null, null, null, Collections.emptyMap(), false);
+    }
+
+    private List<Map<String, Object>> queryTraceRows(String traceId,
+                                                     Long start,
+                                                     Long end,
+                                                     String serviceName,
+                                                     String serviceNamespace,
+                                                     String environment,
+                                                     Map<String, Set<String>> identityValues,
+                                                     Boolean hideInternal) {
+        String workspaceId = AuthTokenRequestContext.currentWorkspaceId();
+        if (!hasTraceRowPushdownFilters(start, end, serviceName, serviceNamespace, environment,
+                workspaceId, identityValues, hideInternal)) {
+            return traceQueryRepository.queryTraceRows(traceId, TRACE_DETAIL_LIMIT);
+        }
+        return traceQueryRepository.queryTraceRows(
+                traceId,
+                TRACE_DETAIL_LIMIT,
+                start,
+                end,
+                serviceName,
+                serviceNamespace,
+                environment,
+                workspaceId,
+                identityValues,
+                hideInternal
+        );
+    }
+
+    private boolean hasTraceRowPushdownFilters(Long start,
+                                               Long end,
+                                               String serviceName,
+                                               String serviceNamespace,
+                                               String environment,
+                                               String workspaceId,
+                                               Map<String, Set<String>> identityValues,
+                                               Boolean hideInternal) {
+        return start != null
+                || end != null
+                || StringUtils.hasText(serviceName)
+                || StringUtils.hasText(serviceNamespace)
+                || StringUtils.hasText(environment)
+                || StringUtils.hasText(workspaceId)
+                || !CollectionUtils.isEmpty(identityValues)
+                || Boolean.TRUE.equals(hideInternal);
     }
 
     private List<TraceAggregate> aggregateTraceRows(List<Map<String, Object>> rows) {
@@ -235,12 +357,41 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
             if (!StringUtils.hasText(span.getTraceId())) {
                 continue;
             }
+            if (!matchesRequestWorkspace(span)) {
+                continue;
+            }
             TraceAggregate aggregate = traceMap.computeIfAbsent(span.getTraceId(), TraceAggregate::new);
             aggregate.accept(span);
         }
         return traceMap.values().stream().map(TraceAggregate::normalize).toList();
     }
 
+    private boolean matchesRequestWorkspace(TraceSpanNodeDto span) {
+        String workspaceId = AuthTokenRequestContext.currentWorkspaceId();
+        if (!StringUtils.hasText(workspaceId)) {
+            return true;
+        }
+        String spanWorkspaceId = resolveWorkspaceId(span);
+        String normalizedWorkspaceId = AuthTokenScopes.normalizeWorkspaceId(workspaceId);
+        if (!StringUtils.hasText(spanWorkspaceId)) {
+            return AuthTokenScopes.DEFAULT_WORKSPACE_ID.equals(normalizedWorkspaceId);
+        }
+        return normalizedWorkspaceId.equals(AuthTokenScopes.normalizeWorkspaceId(spanWorkspaceId));
+    }
+
+    private String resolveWorkspaceId(TraceSpanNodeDto span) {
+        if (span == null || CollectionUtils.isEmpty(span.getResourceAttributes())) {
+            return null;
+        }
+        for (String key : WORKSPACE_RESOURCE_KEYS) {
+            String value = trimText(span.getResourceAttributes().get(key));
+            if (StringUtils.hasText(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
     private boolean matchesTraceFilters(TraceAggregate trace, Map<String, Set<String>> identityValues, Long start, Long end,
                                         String traceId, Boolean errorOnly, String serviceName, String serviceNamespace,
                                         String environment, Boolean hideInternal) {
@@ -358,6 +509,14 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
         }
     }
 
+    private String preferredIdentityValue(Map<String, Set<String>> identityValues, String key) {
+        Set<String> values = identityValues.get(key);
+        if (CollectionUtils.isEmpty(values)) {
+            return null;
+        }
+        return values.stream().filter(StringUtils::hasText).findFirst().orElse(null);
+    }
+
     private TraceListItemDto toTraceListItem(TraceAggregate aggregate) {
         return new TraceListItemDto(
                 aggregate.getTraceId(),
@@ -373,6 +532,27 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
         );
     }
 
+    private TraceListItemDto toTraceListItem(Map<String, Object> row) {
+        Map<String, String> resourceAttributes = parseAttributes(row.get("resource_attributes"), "resource_attributes.", row);
+        String serviceName = defaultText(readText(row, "service_name"), resourceAttributes.get("service.name"));
+        String serviceNamespace = defaultText(readText(row, "service_namespace"),
+                resourceAttributes.get("service.namespace"));
+        String status = normalizeStatus(defaultText(readText(row, "span_status_code"), readText(row, "status")));
+        Integer errorSpanCount = readNonNegativeIntValue(row, "error_span_count", "errorSpanCount");
+        return new TraceListItemDto(
+                readText(row, "trace_id"),
+                defaultText(readText(row, "root_span_id"), readText(row, "span_id")),
+                serviceName,
+                serviceNamespace,
+                defaultText(readText(row, "root_span_name"), defaultText(readText(row, "span_name"), readText(row, "name"))),
+                readNonNegativeLong(row, "duration_nano"),
+                status,
+                readTimestamp(row, "timestamp"),
+                errorSpanCount == null ? ("error".equals(status) ? 1 : 0) : errorSpanCount,
+                resourceAttributes
+        );
+    }
+
     private TraceDetailDto toTraceDetail(TraceAggregate aggregate) {
         return new TraceDetailDto(
                 aggregate.getTraceId(),
@@ -405,7 +585,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
         span.setTraceState(readText(row, "trace_state"));
         span.setScopeName(readText(row, "scope_name"));
         span.setScopeVersion(readText(row, "scope_version"));
-        span.setDurationNanos(readLong(row, "duration_nano"));
+        span.setDurationNanos(readNonNegativeLong(row, "duration_nano"));
         span.setStartTime(readTimestamp(row, "timestamp"));
         span.setHighlighted(isErrorStatus(status));
         span.setResourceAttributes(resourceAttributes);
@@ -430,7 +610,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
                     readLongValue(item, "time_unix_nano", "timeUnixNano"),
                     defaultText(readTextValue(item, "name"), readTextValue(item, "event_name")),
                     readObjectMap(item, "attributes"),
-                    readIntValue(item, "dropped_attributes_count", "droppedAttributesCount")
+                    readNonNegativeIntValue(item, "dropped_attributes_count", "droppedAttributesCount")
             ));
         }
         return events;
@@ -451,7 +631,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
                     defaultText(readTextValue(item, "span_id"), readTextValue(item, "spanId")),
                     defaultText(readTextValue(item, "trace_state"), readTextValue(item, "traceState")),
                     readObjectMap(item, "attributes"),
-                    readIntValue(item, "dropped_attributes_count", "droppedAttributesCount")
+                    readNonNegativeIntValue(item, "dropped_attributes_count", "droppedAttributesCount")
             ));
         }
         return links;
@@ -507,18 +687,9 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
             if (!StringUtils.hasText(key) || !row.containsKey(key)) {
                 continue;
             }
-            Object value = row.get(key);
-            if (value instanceof Number number) {
-                return number.longValue();
-            }
-            String text = trimText(Objects.toString(value, null));
-            if (!StringUtils.hasText(text)) {
-                continue;
-            }
-            try {
-                return Long.parseLong(text);
-            } catch (NumberFormatException ignored) {
-                // Try next key.
+            Long value = coerceLong(row.get(key));
+            if (value != null) {
+                return value;
             }
         }
         return null;
@@ -526,7 +697,21 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
 
     private Integer readIntValue(Map<String, Object> row, String... keys) {
         Long value = readLongValue(row, keys);
-        return value == null ? null : value.intValue();
+        if (value == null) {
+            return null;
+        }
+        if (value > Integer.MAX_VALUE) {
+            return Integer.MAX_VALUE;
+        }
+        if (value < Integer.MIN_VALUE) {
+            return Integer.MIN_VALUE;
+        }
+        return value.intValue();
+    }
+
+    private Integer readNonNegativeIntValue(Map<String, Object> row, String... keys) {
+        Integer value = readIntValue(row, keys);
+        return value == null ? null : Math.max(0, value);
     }
 
     private Map<String, String> parseAttributes(Object rawValue, String prefix, Map<String, Object> row) {
@@ -610,7 +795,24 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
     }
 
     private Long readLong(Map<String, Object> row, String key) {
-        Object value = row.get(key);
+        return coerceLong(row.get(key));
+    }
+
+    private Long readNonNegativeLong(Map<String, Object> row, String key) {
+        Long value = readLong(row, key);
+        return value == null ? null : Math.max(0L, value);
+    }
+
+    private Long coerceLong(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof BigInteger bigInteger) {
+            return clampLong(bigInteger);
+        }
+        if (value instanceof BigDecimal bigDecimal) {
+            return clampLong(bigDecimal);
+        }
         if (value instanceof Number number) {
             return number.longValue();
         }
@@ -621,8 +823,32 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
         try {
             return Long.parseLong(text);
         } catch (NumberFormatException ex) {
-            return null;
+            try {
+                return clampLong(new BigInteger(text));
+            } catch (NumberFormatException ignored) {
+                return null;
+            }
+        }
+    }
+
+    private Long clampLong(BigInteger value) {
+        if (value.compareTo(LONG_MAX_VALUE) > 0) {
+            return Long.MAX_VALUE;
+        }
+        if (value.compareTo(LONG_MIN_VALUE) < 0) {
+            return Long.MIN_VALUE;
+        }
+        return value.longValue();
+    }
+
+    private Long clampLong(BigDecimal value) {
+        if (value.compareTo(LONG_MAX_DECIMAL) > 0) {
+            return Long.MAX_VALUE;
+        }
+        if (value.compareTo(LONG_MIN_DECIMAL) < 0) {
+            return Long.MIN_VALUE;
         }
+        return value.longValue();
     }
 
     private Long readTimestamp(Map<String, Object> row, String key) {
@@ -630,35 +856,61 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
         if (value instanceof Timestamp timestamp) {
             return timestamp.toInstant().toEpochMilli();
         }
+        if (value instanceof java.util.Date date) {
+            return date.getTime();
+        }
         if (value instanceof LocalDateTime dateTime) {
             return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
         }
         if (value instanceof Instant instant) {
             return instant.toEpochMilli();
         }
+        if (value instanceof ZonedDateTime dateTime) {
+            return dateTime.toInstant().toEpochMilli();
+        }
         if (value instanceof Number number) {
-            long numeric = number.longValue();
-            if (numeric > 9_999_999_999L) {
-                return numeric / 1_000_000L;
-            }
-            return numeric;
+            return normalizeEpochMillis(number.longValue());
         }
         String text = trimText(Objects.toString(value, null));
         if (!StringUtils.hasText(text)) {
             return null;
         }
+        if (text.matches("-?\\d+")) {
+            Long numeric = coerceLong(text);
+            return numeric == null ? null : normalizeEpochMillis(numeric);
+        }
         try {
             return Instant.parse(text).toEpochMilli();
+        } catch (Exception ignored) {
+            // Try offset and local date time fallbacks.
+        }
+        String normalizedText = text.replace(' ', 'T');
+        try {
+            return OffsetDateTime.parse(normalizedText).toInstant().toEpochMilli();
         } catch (Exception ignored) {
             // Try local date time fallback.
         }
         try {
-            return LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+            return LocalDateTime.parse(normalizedText).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
         } catch (Exception ignored) {
             return null;
         }
     }
 
+    private long normalizeEpochMillis(long numeric) {
+        long magnitude = numeric == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(numeric);
+        if (magnitude < 100_000_000_000L) {
+            return numeric * 1_000L;
+        }
+        if (magnitude < 100_000_000_000_000L) {
+            return numeric;
+        }
+        if (magnitude < 100_000_000_000_000_000L) {
+            return numeric / 1_000L;
+        }
+        return numeric / 1_000_000L;
+    }
+
     private String normalizeStatus(String rawStatus) {
         String normalized = trimText(rawStatus);
         if (normalized == null) {
@@ -811,8 +1063,10 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
             for (TraceSpanNodeDto root : roots) {
                 appendNode(root, children, ordered);
             }
-            if (ordered.isEmpty()) {
-                ordered.addAll(this.spans);
+            for (TraceSpanNodeDto span : this.spans) {
+                if (!ordered.contains(span)) {
+                    ordered.add(span);
+                }
             }
             return ordered;
         }
diff --git a/hertzbeat-observability/src/main/resources/greptime/flows/hertzbeat_apm_red_1m.sql b/hertzbeat-observability/src/main/resources/greptime/flows/hertzbeat_apm_red_1m.sql
index f4ec606247..25499c4bf6 100644
--- a/hertzbeat-observability/src/main/resources/greptime/flows/hertzbeat_apm_red_1m.sql
+++ b/hertzbeat-observability/src/main/resources/greptime/flows/hertzbeat_apm_red_1m.sql
@@ -20,16 +20,20 @@ SINK TO hertzbeat_apm_red_1m
 EXPIRE AFTER '6 hours'::INTERVAL
 AS SELECT
   date_bin('1 minute'::INTERVAL, "timestamp") AS time_window,
-  service_name,
-  span_name AS operation,
-  span_kind,
+  COALESCE(NULLIF(service_name, ''), 'unknown_service') AS service_name,
+  COALESCE(NULLIF(span_name, ''), 'unknown_operation') AS operation,
+  CASE
+    WHEN span_kind IN ('SPAN_KIND_SERVER', 'SERVER') THEN 'SERVER'
+    WHEN span_kind IN ('SPAN_KIND_CONSUMER', 'CONSUMER') THEN 'CONSUMER'
+    ELSE 'UNKNOWN'
+  END AS span_kind,
   json_get_string(resource_attributes, '$["hertzbeat.workspace_id"]') AS workspace_id,
   json_get_string(resource_attributes, '$["hertzbeat.entity_id"]') AS entity_id,
-  json_get_string(resource_attributes, '$["deployment.environment"]') AS deployment_environment,
+  json_get_string(resource_attributes, '$["deployment.environment.name"]') AS deployment_environment,
   json_get_string(resource_attributes, '$["service.namespace"]') AS service_namespace,
   COUNT(*) AS calls_total,
-  SUM(CASE WHEN span_status_code = 'STATUS_CODE_ERROR' THEN 1 ELSE 0 END) AS error_total,
-  SUM(duration_nano) AS duration_sum_nano,
+  SUM(CASE WHEN span_status_code IN ('STATUS_CODE_ERROR', 'ERROR') THEN 1 ELSE 0 END) AS error_total,
+  COALESCE(SUM(duration_nano), 0) AS duration_sum_nano,
   COUNT(duration_nano) AS duration_count,
   uddsketch_state(128, 0.01, duration_nano) AS duration_sketch
 FROM hzb_traces
diff --git a/hertzbeat-observability/src/main/resources/greptime/tables/hzb_traces.sql b/hertzbeat-observability/src/main/resources/greptime/tables/hzb_traces.sql
new file mode 100644
index 0000000000..3aa7f0ee36
--- /dev/null
+++ b/hertzbeat-observability/src/main/resources/greptime/tables/hzb_traces.sql
@@ -0,0 +1,21 @@
+CREATE TABLE IF NOT EXISTS hzb_traces (
+  "timestamp" TIMESTAMP(9) TIME INDEX,
+  "timestamp_end" TIMESTAMP(9) NULL,
+  "duration_nano" BIGINT UNSIGNED NULL,
+  "trace_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'),
+  "span_id" STRING NULL,
+  "parent_span_id" STRING NULL,
+  "span_kind" STRING NULL,
+  "span_name" STRING NULL,
+  "span_status_code" STRING NULL,
+  "span_status_message" STRING NULL,
+  "trace_state" STRING NULL,
+  "scope_name" STRING NULL,
+  "scope_version" STRING NULL,
+  "service_name" STRING NULL,
+  "resource_attributes" JSON NULL,
+  "span_attributes" JSON NULL,
+  "span_events" JSON NULL,
+  "span_links" JSON NULL,
+  PRIMARY KEY("service_name")
+) WITH (append_mode = true, table_data_model = 'greptime_trace_v1');
diff --git a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/config/OpenTelemetryConfigTest.java b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/config/OpenTelemetryConfigTest.java
index f4c5602c2d..89a3575f16 100644
--- a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/config/OpenTelemetryConfigTest.java
+++ b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/config/OpenTelemetryConfigTest.java
@@ -20,7 +20,10 @@ package org.apache.hertzbeat.observability.config;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
 import java.util.Map;
+import org.apache.hertzbeat.observability.ingestion.forwarder.GreptimeOtlpForwarder;
 import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
 import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
 import org.junit.jupiter.api.Test;
@@ -44,5 +47,61 @@ class OpenTelemetryConfigTest {
         );
 
         assertEquals(WarehouseConstants.LOG_TABLE_NAME, headers.get("X-Greptime-Log-Table-Name"));
+        assertEquals(GreptimeOtlpForwarder.LOG_PIPELINE_NAME, headers.get("X-Greptime-Log-Pipeline-Name"));
+    }
+
+    @Test
+    void greptimeSdkLogAndTraceExportersUseConfiguredDatabaseHeader() throws Exception {
+        GreptimeProperties properties = new GreptimeProperties(true, "127.0.0.1:4001",
+                "http://127.0.0.1:4000", " observability ", "greptime", "greptime", "1d");
+
+        Map<String, String> logHeaders = greptimeHeaders("buildGreptimeOtlpLogHeaders", properties);
+        Map<String, String> traceHeaders = greptimeHeaders("buildGreptimeOtlpTraceHeaders", properties);
+
+        assertEquals("observability", logHeaders.get("X-Greptime-DB-Name"));
+        assertEquals("observability", traceHeaders.get("X-Greptime-DB-Name"));
+        assertEquals("hzb_traces", traceHeaders.get("X-Greptime-Trace-Table-Name"));
+        assertEquals("greptime_trace_v1", traceHeaders.get("X-Greptime-Pipeline-Name"));
+    }
+
+    @Test
+    void greptimeSdkLogAndTraceExportersNormalizeConfiguredEndpoint() throws Exception {
+        GreptimeProperties properties = new GreptimeProperties(true, "127.0.0.1:4001",
+                "  http://greptime:4000///  ", "public", "greptime", "greptime", "1d");
+
+        assertEquals("http://greptime:4000/v1/otlp/v1/logs",
+                greptimeEndpoint(properties, "/v1/otlp/v1/logs"));
+        assertEquals("http://greptime:4000/v1/otlp/v1/traces",
+                greptimeEndpoint(properties, "/v1/otlp/v1/traces"));
+    }
+
+    @Test
+    void greptimeSdkLogAndTraceExportersTrimBasicAuthCredentials() throws Exception {
+        GreptimeProperties properties = new GreptimeProperties(true, "127.0.0.1:4001",
+                "http://127.0.0.1:4000", "public", " greptime ", " secret ", "1d");
+        String expectedAuthorization = "Basic "
+                + Base64.getEncoder().encodeToString("greptime:secret".getBytes(StandardCharsets.UTF_8));
+
+        assertEquals(expectedAuthorization,
+                greptimeHeaders("buildGreptimeOtlpLogHeaders", properties).get("Authorization"));
+        assertEquals(expectedAuthorization,
+                greptimeHeaders("buildGreptimeOtlpTraceHeaders", properties).get("Authorization"));
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, String> greptimeHeaders(String methodName, GreptimeProperties properties) throws Exception {
+        Method method = OpenTelemetryConfig.class.getDeclaredMethod(methodName, GreptimeProperties.class);
+        method.setAccessible(true);
+        return (Map<String, String>) method.invoke(new OpenTelemetryConfig(), properties);
+    }
+
+    private String greptimeEndpoint(GreptimeProperties properties, String path) throws Exception {
+        Method method = OpenTelemetryConfig.class.getDeclaredMethod(
+                "greptimeOtlpEndpoint",
+                GreptimeProperties.class,
+                String.class
+        );
+        method.setAccessible(true);
+        return (String) method.invoke(new OpenTelemetryConfig(), properties, path);
     }
 }
diff --git a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/adapter/OtlpLogProtocolAdapterTest.java b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/adapter/OtlpLogProtocolAdapterTest.java
index ea5fd6eaef..5fcbf88afa 100644
--- a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/adapter/OtlpLogProtocolAdapterTest.java
+++ b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/adapter/OtlpLogProtocolAdapterTest.java
@@ -28,6 +28,7 @@ import io.opentelemetry.proto.logs.v1.ScopeLogs;
 import io.opentelemetry.proto.resource.v1.Resource;
 import io.opentelemetry.proto.common.v1.InstrumentationScope;
 import org.apache.hertzbeat.common.entity.log.LogEntry;
+import org.apache.hertzbeat.common.entity.observability.TelemetryIntakeSignalEvent;
 import org.apache.hertzbeat.common.queue.CommonDataQueue;
 import org.apache.hertzbeat.observability.logs.sse.LogSseManager;
 import org.junit.jupiter.api.BeforeEach;
@@ -45,7 +46,9 @@ import java.util.Map;
 import java.util.zip.GZIPOutputStream;
 
 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.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyList;
@@ -146,6 +149,58 @@ class OtlpLogProtocolAdapterTest {
         assertEquals("binary log message", capturedList.get(0).getBody());
     }
 
+    @Test
+    void testIngestBinaryWithGzipContentEncodingOptionalWhitespace() throws Exception {
+        byte[] binaryPayload = gzip(createValidOtlpLogBinaryPayload());
+        HttpHeaders headers = new HttpHeaders();
+        headers.add(HttpHeaders.CONTENT_ENCODING, " gzip ");
+
+        adapter.ingestBinary(binaryPayload, headers);
+
+        ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
+        verify(commonDataQueue, never()).sendLogEntryToStorageBatch(anyList());
+        verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(listCaptor.capture());
+        List<LogEntry> capturedList = listCaptor.getValue();
+        assertNotNull(capturedList);
+        assertEquals(1, capturedList.size());
+        assertEquals("binary log message", capturedList.get(0).getBody());
+    }
+
+    @Test
+    void testIngestBinaryWithGzipContentEncodingBlankFirstValue() throws Exception {
+        byte[] binaryPayload = gzip(createValidOtlpLogBinaryPayload());
+        HttpHeaders headers = new HttpHeaders();
+        headers.add(HttpHeaders.CONTENT_ENCODING, " ");
+        headers.add(HttpHeaders.CONTENT_ENCODING, "gzip");
+
+        adapter.ingestBinary(binaryPayload, headers);
+
+        ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
+        verify(commonDataQueue, never()).sendLogEntryToStorageBatch(anyList());
+        verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(listCaptor.capture());
+        List<LogEntry> capturedList = listCaptor.getValue();
+        assertNotNull(capturedList);
+        assertEquals(1, capturedList.size());
+        assertEquals("binary log message", capturedList.get(0).getBody());
+    }
+
+    @Test
+    void testIngestBinaryWithGzipContentEncodingCommaSeparatedBlankValue() throws Exception {
+        byte[] binaryPayload = gzip(createValidOtlpLogBinaryPayload());
+        HttpHeaders headers = new HttpHeaders();
+        headers.add(HttpHeaders.CONTENT_ENCODING, " , gzip ");
+
+        adapter.ingestBinary(binaryPayload, headers);
+
+        ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
+        verify(commonDataQueue, never()).sendLogEntryToStorageBatch(anyList());
+        verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(listCaptor.capture());
+        List<LogEntry> capturedList = listCaptor.getValue();
+        assertNotNull(capturedList);
+        assertEquals(1, capturedList.size());
+        assertEquals("binary log message", capturedList.get(0).getBody());
+    }
+
     @Test
     void testIngestWithValidOtlpLogData() throws Exception {
         String otlpPayload = createValidOtlpLogPayload();
@@ -238,6 +293,98 @@ class OtlpLogProtocolAdapterTest {
         assertEquals(1, capturedEntry.getTraceFlags());
     }
 
+    @Test
+    void allZeroTraceAndSpanIdsAreNotPublishedAsLocalCorrelationEvidence() {
+        ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder()
+            .addResourceLogs(ResourceLogs.newBuilder()
+                .setResource(Resource.newBuilder()
+                    .addAttributes(KeyValue.newBuilder()
+                        .setKey("service.name")
+                        .setValue(AnyValue.newBuilder().setStringValue("checkout").build())
+                        .build())
+                    .build())
+                .addScopeLogs(ScopeLogs.newBuilder()
+                    .setScope(InstrumentationScope.newBuilder().build())
+                    .addLogRecords(LogRecord.newBuilder()
+                        .setTimeUnixNano(System.currentTimeMillis() * 1_000_000)
+                        .setBody(AnyValue.newBuilder().setStringValue("zero ids should stay unlinked").build())
+                        .setTraceId(ByteString.fromHex("00000000000000000000000000000000"))
+                        .setSpanId(ByteString.fromHex("0000000000000000"))
+                        .build())
+                    .build())
+                .build())
+            .build();
+
+        adapter.ingestBinary(request.toByteArray());
+
+        ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
+        verify(commonDataQueue, never()).sendLogEntryToStorageBatch(anyList());
+        verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(listCaptor.capture());
+        verify(logSseManager, times(1)).broadcast(any(LogEntry.class));
+
+        LogEntry capturedEntry = listCaptor.getValue().get(0);
+        assertNull(capturedEntry.getTraceId());
+        assertNull(capturedEntry.getSpanId());
+
+        ArgumentCaptor<TelemetryIntakeSignalEvent> eventCaptor =
+                ArgumentCaptor.forClass(TelemetryIntakeSignalEvent.class);
+        verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture());
+        assertNull(eventCaptor.getValue().getTraceId());
+        assertNull(eventCaptor.getValue().getSpanId());
+    }
+
+    @Test
+    void redactsSensitiveLogBodyAndAttributesBeforeLocalPublication() {
+        ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder()
+            .addResourceLogs(ResourceLogs.newBuilder()
+                .setResource(Resource.newBuilder()
+                    .addAttributes(KeyValue.newBuilder()
+                        .setKey("service.name")
+                        .setValue(AnyValue.newBuilder().setStringValue("checkout").build())
+                        .build())
+                    .addAttributes(KeyValue.newBuilder()
+                        .setKey("cloud.auth.token")
+                        .setValue(AnyValue.newBuilder().setStringValue("resource-token").build())
+                        .build())
+                    .build())
+                .addScopeLogs(ScopeLogs.newBuilder()
+                    .setScope(InstrumentationScope.newBuilder().build())
+                    .addLogRecords(LogRecord.newBuilder()
+                        .setTimeUnixNano(System.currentTimeMillis() * 1_000_000)
+                        .setBody(AnyValue.newBuilder()
+                            .setStringValue("failed login password=hunter2 token=abc123")
+                            .build())
+                        .addAttributes(KeyValue.newBuilder()
+                            .setKey("http.request.header.authorization")
+                            .setValue(AnyValue.newBuilder().setStringValue("Bearer live-token").build())
+                            .build())
+                        .addAttributes(KeyValue.newBuilder()
+                            .setKey("user.id")
+                            .setValue(AnyValue.newBuilder().setStringValue("alice").build())
+                            .build())
+                        .build())
+                    .build())
+                .build())
+            .build();
+
+        adapter.ingestBinary(request.toByteArray());
+
+        ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
+        verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(listCaptor.capture());
+        LogEntry capturedEntry = listCaptor.getValue().get(0);
+        assertEquals("checkout", capturedEntry.getResource().get("service_name"));
+        assertEquals("[REDACTED]", capturedEntry.getResource().get("cloud_auth_token"));
+        assertEquals("failed login password=[REDACTED] token=[REDACTED]", capturedEntry.getBody());
+        assertEquals("[REDACTED]", capturedEntry.getAttributes().get("http_request_header_authorization"));
+        assertEquals("alice", capturedEntry.getAttributes().get("user_id"));
+
+        String rendered = capturedEntry.toString();
+        assertFalse(rendered.contains("resource-token"));
+        assertFalse(rendered.contains("hunter2"));
+        assertFalse(rendered.contains("abc123"));
+        assertFalse(rendered.contains("live-token"));
+    }
+
     @Test
     void testIngestWithInvalidJsonContent() {
         String invalidJson = "{ invalid json content }";
diff --git a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventReaderTest.java b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventReaderTest.java
new file mode 100644
index 0000000000..2713aab2a0
--- /dev/null
+++ b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/audit/GreptimeOtlpIngestionAuditEventReaderTest.java
@@ -0,0 +1,967 @@
+/*
+ * 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.hertzbeat.observability.ingestion.audit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor;
... 15915 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org