diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml b/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml
new file mode 100644
index 00000000..b6ee8eda
--- /dev/null
+++ b/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml
@@ -0,0 +1,57 @@
+
+
+
+ 4.0.0
+
+ apollo-plugin
+ com.ctrip.framework.apollo
+ ${revision}
+ ../pom.xml
+
+
+ apollo-plugin-client-opentelemetry
+ Apollo Plugin OpenTelemetry
+ jar
+
+
+
+ com.ctrip.framework.apollo
+ apollo-client
+ provided
+
+
+ io.opentelemetry
+ opentelemetry-api
+ 1.35.0
+
+
+
+ junit
+ junit
+ test
+
+
+ org.mockito
+ mockito-inline
+ test
+
+
+
+
\ No newline at end of file
diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java
new file mode 100644
index 00000000..62dcc1cc
--- /dev/null
+++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2026 Apollo Authors
+ *
+ * Licensed 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 com.ctrip.framework.apollo.monitor.internal.exporter.impl;
+
+import com.ctrip.framework.apollo.core.utils.DeferredLoggerFactory;
+import com.ctrip.framework.apollo.monitor.internal.exporter.AbstractApolloClientMetricsExporter;
+import com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter;
+import com.google.common.collect.Maps;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.opentelemetry.api.metrics.DoubleGaugeBuilder;
+import io.opentelemetry.api.metrics.LongCounter;
+import io.opentelemetry.api.metrics.LongCounterBuilder;
+import io.opentelemetry.api.metrics.Meter;
+import io.opentelemetry.api.metrics.ObservableDoubleGauge;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
+import org.slf4j.Logger;
+
+/**
+ * OpenTelemetry implementation of Apollo client metrics exporter.
+ * Only uses OpenTelemetry API layer (no SDK dependency).
+ *
+ * @author teaho2015@gmail.com
+ */
+public class OpenTelemetryApolloClientMetricsExporter extends
+ AbstractApolloClientMetricsExporter implements ApolloClientMetricsExporter {
+
+ private static final String OPENTELEMETRY = "opentelemetry";
+ private static final String METER_NAME = "apollo-client";
+ private static final String COUNTER_UNIT = "1";
+ private static final String GAUGE_UNIT = "1";
+
+ private final Logger logger = DeferredLoggerFactory.getLogger(
+ OpenTelemetryApolloClientMetricsExporter.class);
+
+ private Meter meter;
+ private Map counterMap;
+ private Map gaugeMap;
+ private Map> gaugeValueMap;
+ private Map attributesCache;
+
+ @Override
+ public void doInit() {
+ // Get meter from global OpenTelemetry
+ meter = GlobalOpenTelemetry.get().getMeter(METER_NAME);
+ // Initialize maps
+ counterMap = new ConcurrentHashMap<>();
+ gaugeMap = new ConcurrentHashMap<>();
+ gaugeValueMap = new ConcurrentHashMap<>();
+ attributesCache = new ConcurrentHashMap<>();
+ logger.info("OpenTelemetry metrics exporter initialized with meter: {}", METER_NAME);
+ }
+
+ @Override
+ public boolean isSupport(String form) {
+ return OPENTELEMETRY.equals(form);
+ }
+
+ @Override
+ public void registerOrUpdateCounterSample(String name, Map tags,
+ double incrValue) {
+ try {
+ if (meter == null) {
+ logger.warn("OpenTelemetry meter not initialized, skipping counter registration for '{}'", name);
+ return;
+ }
+
+ LongCounter counter = counterMap.computeIfAbsent(name, this::createCounter);
+
+ Attributes attributes = getOrCreateAttributes(tags);
+ counter.add((long) incrValue, attributes);
+
+ logger.debug("Updated OpenTelemetry counter '{}' with value: {}, tags: {}",
+ name, incrValue, tags);
+ } catch (Exception e) {
+ logger.error("Failed to register or update OpenTelemetry counter '{}'", name, e);
+ }
+ }
+
+ private LongCounter createCounter(String name) {
+ LongCounterBuilder builder = meter.counterBuilder(name)
+ .setDescription("Apollo counter metrics")
+ .setUnit(COUNTER_UNIT);
+
+ // Build the counter
+ return builder.build();
+ }
+
+ @Override
+ public void registerOrUpdateGaugeSample(String name, Map tags, double value) {
+ if (meter == null) {
+ logger.warn("OpenTelemetry meter not initialized, skipping gauge registration for '{}'", name);
+ return;
+ }
+
+ // Store the gauge value
+ String gaugeKey = getGaugeKey(name, tags);
+ gaugeValueMap.put(gaugeKey, new AtomicReference<>(value));
+
+ // Register gauge if not already registered
+ gaugeMap.computeIfAbsent(gaugeKey, key -> createGauge(name, tags, gaugeKey));
+
+ logger.debug("Updated OpenTelemetry gauge '{}' with value: {}, tags: {}",
+ name, value, tags);
+ }
+
+ private ObservableDoubleGauge createGauge(String name, Map tags, String gaugeKey) {
+ Attributes attributes = getOrCreateAttributes(tags);
+
+ DoubleGaugeBuilder gaugeBuilder = meter.gaugeBuilder(name)
+ .setDescription("Apollo gauge metrics")
+ .setUnit(GAUGE_UNIT);
+
+ // Register callback for gauge value
+ return gaugeBuilder.buildWithCallback(measurement -> {
+ AtomicReference valueRef = gaugeValueMap.get(gaugeKey);
+ if (valueRef != null) {
+ Double value = valueRef.get();
+ if (value != null) {
+ measurement.record(value, attributes);
+ }
+ }
+ });
+ }
+
+ private Attributes getOrCreateAttributes(Map tags) {
+ if (tags == null || tags.isEmpty()) {
+ return Attributes.empty();
+ }
+
+ // Create cache key from sorted tag entries for consistency
+ String cacheKey = createCacheKey(tags);
+
+ return attributesCache.computeIfAbsent(cacheKey, key -> {
+ AttributesBuilder builder = Attributes.builder();
+ tags.forEach(builder::put);
+ return builder.build();
+ });
+ }
+
+ private String createCacheKey(Map tags) {
+ // Sort keys to ensure consistent cache key
+ return tags.entrySet().stream()
+ .sorted(Map.Entry.comparingByKey())
+ .map(entry -> entry.getKey() + "=" + entry.getValue())
+ .reduce((a, b) -> a + ";" + b)
+ .orElse("");
+ }
+
+ private String getGaugeKey(String name, Map tags) {
+ return name + ":" + createCacheKey(tags);
+ }
+
+ @Override
+ public String response() {
+ // Return simple status information since we're only using API layer
+ int counterCount = counterMap != null ? counterMap.size() : 0;
+ int gaugeCount = gaugeMap != null ? gaugeMap.size() : 0;
+ int attributesCount = attributesCache != null ? attributesCache.size() : 0;
+
+ String meterStatus = (meter != null) ? METER_NAME : "not initialized";
+
+ return String.format(
+ "OpenTelemetry metrics exporter status - Counters: %d, Gauges: %d, Cached attributes: %d, Meter: %s",
+ counterCount, gaugeCount, attributesCount, meterStatus);
+ }
+}
\ No newline at end of file
diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter
new file mode 100644
index 00000000..8e064550
--- /dev/null
+++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter
@@ -0,0 +1 @@
+com.ctrip.framework.apollo.monitor.internal.exporter.impl.OpenTelemetryApolloClientMetricsExporter
\ No newline at end of file
diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java
new file mode 100644
index 00000000..2e7e8c64
--- /dev/null
+++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2022 Apollo Authors
+ *
+ * Licensed 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 com.ctrip.framework.apollo.monitor.internal.exporter.impl;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.metrics.Meter;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class OpenTelemetryApolloClientMetricsExporterTest {
+
+ private OpenTelemetryApolloClientMetricsExporter exporter;
+ private MockedStatic globalOpenTelemetryMock;
+ private OpenTelemetry openTelemetry;
+ private Meter meter;
+
+ @Before
+ public void setUp() {
+ // Mock GlobalOpenTelemetry
+ globalOpenTelemetryMock = mockStatic(GlobalOpenTelemetry.class);
+ openTelemetry = mock(OpenTelemetry.class);
+ meter = mock(Meter.class);
+
+ when(GlobalOpenTelemetry.get()).thenReturn(openTelemetry);
+ when(openTelemetry.getMeter("apollo-client")).thenReturn(meter);
+
+ exporter = new OpenTelemetryApolloClientMetricsExporter();
+ exporter.doInit();
+ }
+
+ @After
+ public void tearDown() {
+ if (globalOpenTelemetryMock != null) {
+ globalOpenTelemetryMock.close();
+ }
+ }
+
+ @Test
+ public void testIsSupport() {
+ assertTrue(exporter.isSupport("opentelemetry"));
+ assertFalse(exporter.isSupport("prometheus"));
+ assertFalse(exporter.isSupport("other"));
+ }
+
+ @Test
+ public void testDoInit() {
+ // Verify that meter was obtained from GlobalOpenTelemetry
+ globalOpenTelemetryMock.verify(() -> GlobalOpenTelemetry.get());
+ verify(openTelemetry).getMeter("apollo-client");
+ }
+
+ @Test
+ public void testRegisterOrUpdateCounterSample() {
+ String name = "test_counter";
+ Map tags = new HashMap<>();
+ tags.put("namespace", "application");
+ tags.put("cluster", "default");
+
+ // Mock counter builder
+ io.opentelemetry.api.metrics.LongCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class);
+ io.opentelemetry.api.metrics.LongCounter counter = mock(io.opentelemetry.api.metrics.LongCounter.class);
+
+ when(meter.counterBuilder(name)).thenReturn(counterBuilder);
+ when(counterBuilder.setDescription("Apollo counter metrics")).thenReturn(counterBuilder);
+ when(counterBuilder.setUnit("1")).thenReturn(counterBuilder);
+ when(counterBuilder.build()).thenReturn(counter);
+
+ // This will create the counter on first call
+ exporter.registerOrUpdateCounterSample(name, tags, 1.0);
+
+ // Verify counter was created
+ verify(meter).counterBuilder(name);
+ verify(counterBuilder).build();
+ }
+
+ @Test
+ public void testRegisterOrUpdateGaugeSample() {
+ String name = "test_gauge";
+ Map tags = new HashMap<>();
+ tags.put("namespace", "application");
+ tags.put("cluster", "default");
+
+ // Mock gauge builder
+ io.opentelemetry.api.metrics.DoubleGaugeBuilder gaugeBuilder = mock(io.opentelemetry.api.metrics.DoubleGaugeBuilder.class);
+ io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = mock(io.opentelemetry.api.metrics.ObservableDoubleGauge.class);
+
+ when(meter.gaugeBuilder(name)).thenReturn(gaugeBuilder);
+ when(gaugeBuilder.setDescription("Apollo gauge metrics")).thenReturn(gaugeBuilder);
+ when(gaugeBuilder.setUnit("1")).thenReturn(gaugeBuilder);
+ when(gaugeBuilder.buildWithCallback(any())).thenReturn(gauge);
+
+ // This will create the gauge on first call
+ exporter.registerOrUpdateGaugeSample(name, tags, 3.14);
+
+ // Verify gauge was created
+ verify(meter).gaugeBuilder(name);
+ verify(gaugeBuilder).buildWithCallback(any());
+ }
+
+ @Test
+ public void testResponse() {
+ String response = exporter.response();
+ assertNotNull(response);
+ assertTrue(response.contains("OpenTelemetry metrics exporter status"));
+ assertTrue(response.contains("Counters: 0"));
+ assertTrue(response.contains("Gauges: 0"));
+ assertTrue(response.contains("Meter: apollo-client"));
+ }
+
+ @Test
+ public void testResponseWithMetrics() {
+ // Register some metrics first
+ Map tags = new HashMap<>();
+ tags.put("test", "value");
+
+ // Mock counter creation
+ io.opentelemetry.api.metrics.LongCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class);
+ io.opentelemetry.api.metrics.LongCounter counter = mock(io.opentelemetry.api.metrics.LongCounter.class);
+ when(meter.counterBuilder("test_counter")).thenReturn(counterBuilder);
+ when(counterBuilder.setDescription(anyString())).thenReturn(counterBuilder);
+ when(counterBuilder.setUnit(anyString())).thenReturn(counterBuilder);
+ when(counterBuilder.build()).thenReturn(counter);
+
+ // Mock gauge creation
+ io.opentelemetry.api.metrics.DoubleGaugeBuilder gaugeBuilder = mock(io.opentelemetry.api.metrics.DoubleGaugeBuilder.class);
+ io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = mock(io.opentelemetry.api.metrics.ObservableDoubleGauge.class);
+ when(meter.gaugeBuilder("test_gauge")).thenReturn(gaugeBuilder);
+ when(gaugeBuilder.setDescription(anyString())).thenReturn(gaugeBuilder);
+ when(gaugeBuilder.setUnit(anyString())).thenReturn(gaugeBuilder);
+ when(gaugeBuilder.buildWithCallback(any())).thenReturn(gauge);
+
+ exporter.registerOrUpdateCounterSample("test_counter", tags, 1.0);
+ exporter.registerOrUpdateGaugeSample("test_gauge", tags, 2.0);
+
+ String response = exporter.response();
+ assertTrue(response.contains("Counters: 1"));
+ assertTrue(response.contains("Gauges: 1"));
+ }
+}
\ No newline at end of file
diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java
new file mode 100644
index 00000000..671df0a8
--- /dev/null
+++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2022 Apollo Authors
+ *
+ * Licensed 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 com.ctrip.framework.apollo.monitor.internal.exporter.impl;
+
+import static org.junit.Assert.*;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class SimpleOpenTelemetryApolloClientMetricsExporterTest {
+
+ private OpenTelemetryApolloClientMetricsExporter exporter;
+
+ @Before
+ public void setUp() {
+ exporter = new OpenTelemetryApolloClientMetricsExporter();
+ // Note: We don't call doInit() because it requires GlobalOpenTelemetry
+ // In a real test environment, you would need to setup OpenTelemetry first
+ }
+
+ @Test
+ public void testIsSupport() {
+ assertTrue(exporter.isSupport("opentelemetry"));
+ assertFalse(exporter.isSupport("prometheus"));
+ assertFalse(exporter.isSupport("other"));
+ assertFalse(exporter.isSupport(null));
+ }
+
+ @Test
+ public void testResponseWithoutInit() {
+ // Even without init, response should return a status message
+ String response = exporter.response();
+ assertNotNull(response);
+ assertTrue(response.contains("OpenTelemetry metrics exporter status"));
+ }
+
+ @Test
+ public void testRegisterOrUpdateCounterSampleWithoutInit() {
+ // This should not throw exception even without init
+ String name = "test_counter";
+ Map tags = new HashMap<>();
+ tags.put("namespace", "application");
+
+ try {
+ exporter.registerOrUpdateCounterSample(name, tags, 1.0);
+ // If we get here, it means no exception was thrown
+ assertTrue(true);
+ } catch (Exception e) {
+ // It's okay if it throws exception when not initialized
+ // This depends on OpenTelemetry API behavior
+ }
+ }
+
+ @Test
+ public void testRegisterOrUpdateGaugeSampleWithoutInit() {
+ // This should not throw exception even without init
+ String name = "test_gauge";
+ Map tags = new HashMap<>();
+ tags.put("namespace", "application");
+
+ try {
+ exporter.registerOrUpdateGaugeSample(name, tags, 3.14);
+ // If we get here, it means no exception was thrown
+ assertTrue(true);
+ } catch (Exception e) {
+ // It's okay if it throws exception when not initialized
+ // This depends on OpenTelemetry API behavior
+ }
+ }
+
+ @Test
+ public void testEmptyTags() {
+ String name = "test_metric";
+ Map emptyTags = new HashMap<>();
+
+ try {
+ exporter.registerOrUpdateCounterSample(name, emptyTags, 1.0);
+ exporter.registerOrUpdateGaugeSample(name, emptyTags, 2.0);
+ assertTrue(true);
+ } catch (Exception e) {
+ // Acceptable if it throws exception
+ }
+ }
+
+ @Test
+ public void testNullTags() {
+ String name = "test_metric";
+
+ try {
+ exporter.registerOrUpdateCounterSample(name, null, 1.0);
+ exporter.registerOrUpdateGaugeSample(name, null, 2.0);
+ assertTrue(true);
+ } catch (Exception e) {
+ // Acceptable if it throws exception for null tags
+ }
+ }
+}
\ No newline at end of file
diff --git a/apollo-plugin/pom.xml b/apollo-plugin/pom.xml
index f0d1b00f..b97e09f4 100644
--- a/apollo-plugin/pom.xml
+++ b/apollo-plugin/pom.xml
@@ -32,6 +32,7 @@
apollo-plugin-log4j2
apollo-plugin-client-prometheus
+ apollo-plugin-client-opentelemetry