You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/04/18 00:00:05 UTC
(skywalking) branch feature/swip11-ios-monitoring created (now e299841ff8)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch feature/swip11-ios-monitoring
in repository https://gitbox.apache.org/repos/asf/skywalking.git
at e299841ff8 Add iOS/iPadOS app monitoring via OpenTelemetry Swift SDK (SWIP-11)
This branch includes the following new commits:
new e8eccef5a0 Add iOS monitoring: IOS layer, SpanListeners, MAL/LAL rules
new e299841ff8 Add iOS/iPadOS app monitoring via OpenTelemetry Swift SDK (SWIP-11)
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking) 01/02: Add iOS monitoring: IOS layer, SpanListeners, MAL/LAL rules
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch feature/swip11-ios-monitoring
in repository https://gitbox.apache.org/repos/asf/skywalking.git
commit e8eccef5a01b9c2de25f40e47b6111e3cae6970b
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Thu Apr 16 14:01:14 2026 +0800
Add iOS monitoring: IOS layer, SpanListeners, MAL/LAL rules
SWIP-11 iOS-specific implementation:
- IOS(47, true) layer in Layer.java
- ios-analyzer module with two SpanListeners:
- IOSLayerSpanListener: detect os.name=iOS, register service with Layer.IOS
- IOSMetricKitSpanListener: extract MetricKit daily stats to MAL, skip trace
- MAL rules (otel-rules/ios/ios-metrickit.yaml): 16 metrics with device/OS labels
- LAL rule (lal/ios-metrickit.yaml): layer:auto crash/hang diagnostic logs
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
oap-server/analyzer/{ => ios-analyzer}/pom.xml | 24 +--
.../ios/listener/IOSLayerSpanListener.java | 85 ++++++++++
.../ios/listener/IOSMetricKitSpanListener.java | 171 +++++++++++++++++++++
...e.skywalking.oap.server.core.trace.SpanListener | 18 +++
oap-server/analyzer/pom.xml | 1 +
.../skywalking/oap/server/core/analysis/Layer.java | 7 +-
.../src/main/resources/lal/ios-metrickit.yaml | 51 ++++++
.../resources/otel-rules/ios/ios-metrickit.yaml | 64 ++++++++
8 files changed, 400 insertions(+), 21 deletions(-)
diff --git a/oap-server/analyzer/pom.xml b/oap-server/analyzer/ios-analyzer/pom.xml
similarity index 68%
copy from oap-server/analyzer/pom.xml
copy to oap-server/analyzer/ios-analyzer/pom.xml
index d521ba4a4d..93f954baed 100644
--- a/oap-server/analyzer/pom.xml
+++ b/oap-server/analyzer/ios-analyzer/pom.xml
@@ -19,39 +19,23 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
- <artifactId>oap-server</artifactId>
+ <artifactId>analyzer</artifactId>
<groupId>org.apache.skywalking</groupId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
- <artifactId>analyzer</artifactId>
- <packaging>pom</packaging>
-
- <modules>
- <module>agent-analyzer</module>
- <module>event-analyzer</module>
- <module>meter-analyzer</module>
- <module>log-analyzer</module>
- <module>hierarchy</module>
- <module>gen-ai-analyzer</module>
- <module>meter-analyzer-scripts-test</module>
- </modules>
+ <artifactId>ios-analyzer</artifactId>
<dependencies>
<dependency>
<groupId>org.apache.skywalking</groupId>
- <artifactId>apm-network</artifactId>
- <version>${project.version}</version>
- </dependency>
- <dependency>
- <groupId>org.apache.skywalking</groupId>
- <artifactId>library-module</artifactId>
+ <artifactId>server-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
- <artifactId>library-util</artifactId>
+ <artifactId>meter-analyzer</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
diff --git a/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSLayerSpanListener.java b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSLayerSpanListener.java
new file mode 100644
index 0000000000..0548991629
--- /dev/null
+++ b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSLayerSpanListener.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.analyzer.ios.listener;
+
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.analysis.Layer;
+import org.apache.skywalking.oap.server.core.analysis.TimeBucket;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.source.ServiceMeta;
+import org.apache.skywalking.oap.server.core.source.SourceReceiver;
+import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
+import org.apache.skywalking.oap.server.core.trace.SpanListener;
+import org.apache.skywalking.oap.server.core.trace.SpanListenerResult;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+
+/**
+ * Detects iOS/iPadOS spans from the {@code os.name} resource attribute and registers
+ * the service with {@link Layer#IOS}.
+ *
+ * <p>Implements phase 1 ({@link #onOTLPSpan}) only — HTTP trace spans are persisted
+ * normally via the Zipkin pipeline; this listener just ensures the service layer is set.
+ */
+@Slf4j
+public class IOSLayerSpanListener implements SpanListener {
+ private SourceReceiver sourceReceiver;
+ private NamingControl namingControl;
+
+ @Override
+ public String[] requiredModules() {
+ return new String[] {CoreModule.NAME};
+ }
+
+ @Override
+ public void init(final ModuleManager moduleManager) {
+ sourceReceiver = moduleManager.find(CoreModule.NAME)
+ .provider()
+ .getService(SourceReceiver.class);
+ namingControl = moduleManager.find(CoreModule.NAME)
+ .provider()
+ .getService(NamingControl.class);
+ }
+
+ @Override
+ public SpanListenerResult onOTLPSpan(final OTLPSpanReader span,
+ final Map<String, String> resourceAttributes,
+ final String scopeName,
+ final String scopeVersion) {
+ final String osName = resourceAttributes.get("os.name");
+ if (!"iOS".equals(osName) && !"iPadOS".equals(osName)) {
+ return SpanListenerResult.CONTINUE;
+ }
+
+ final String serviceName = resourceAttributes.get("service.name");
+ if (serviceName == null || serviceName.isEmpty()) {
+ return SpanListenerResult.CONTINUE;
+ }
+
+ // Register the service with IOS layer
+ final ServiceMeta serviceMeta = new ServiceMeta();
+ serviceMeta.setName(namingControl.formatServiceName(serviceName));
+ serviceMeta.setLayer(Layer.IOS);
+ serviceMeta.setTimeBucket(TimeBucket.getMinuteTimeBucket(
+ span.endTimeNanos() / 1_000_000));
+ sourceReceiver.receive(serviceMeta);
+
+ return SpanListenerResult.CONTINUE;
+ }
+}
diff --git a/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSMetricKitSpanListener.java b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSMetricKitSpanListener.java
new file mode 100644
index 0000000000..87f86c6b52
--- /dev/null
+++ b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSMetricKitSpanListener.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.analyzer.ios.listener;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.skywalking.oap.meter.analyzer.v2.MetricConvert;
+import org.apache.skywalking.oap.meter.analyzer.v2.dsl.Sample;
+import org.apache.skywalking.oap.meter.analyzer.v2.dsl.SampleFamily;
+import org.apache.skywalking.oap.meter.analyzer.v2.dsl.SampleFamilyBuilder;
+import org.apache.skywalking.oap.meter.analyzer.v2.prometheus.rule.Rule;
+import org.apache.skywalking.oap.meter.analyzer.v2.prometheus.rule.Rules;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.analysis.meter.MeterSystem;
+import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
+import org.apache.skywalking.oap.server.core.trace.SpanListener;
+import org.apache.skywalking.oap.server.core.trace.SpanListenerResult;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+
+/**
+ * Extracts Apple MetricKit daily statistics from OTLP spans and converts them
+ * to MAL metrics. MetricKit spans are NOT stored as traces (24-hour duration).
+ *
+ * <p>Detection: {@code scopeName == "MetricKit"} AND {@code spanName == "MXMetricPayload"}.
+ *
+ * <p>The listener converts span attributes into labeled {@link SampleFamily} samples
+ * and feeds them into the MAL pipeline via {@link MetricConvert}.
+ */
+@Slf4j
+public class IOSMetricKitSpanListener implements SpanListener {
+ private List<MetricConvert> converters;
+
+ @Override
+ public String[] requiredModules() {
+ return new String[] {CoreModule.NAME};
+ }
+
+ @Override
+ public void init(final ModuleManager moduleManager) {
+ final MeterSystem meterSystem = moduleManager.find(CoreModule.NAME)
+ .provider()
+ .getService(MeterSystem.class);
+ try {
+ final List<Rule> rules = Rules.loadRules(
+ "otel-rules", java.util.Collections.singletonList("ios"));
+ converters = rules.stream()
+ .map(r -> new MetricConvert(r, meterSystem))
+ .collect(java.util.stream.Collectors.toList());
+ } catch (java.io.IOException e) {
+ log.warn("Failed to load iOS MAL rules from otel-rules/ios/, "
+ + "MetricKit metrics will not be processed", e);
+ converters = java.util.Collections.emptyList();
+ }
+ log.info("IOSMetricKitSpanListener initialized with {} MAL converters", converters.size());
+ }
+
+ @Override
+ public SpanListenerResult onOTLPSpan(final OTLPSpanReader span,
+ final Map<String, String> resourceAttributes,
+ final String scopeName,
+ final String scopeVersion) {
+ if (!"MetricKit".equals(scopeName) || !"MXMetricPayload".equals(span.spanName())) {
+ return SpanListenerResult.CONTINUE;
+ }
+
+ if (converters.isEmpty()) {
+ log.debug("No MAL converters for iOS MetricKit, skipping");
+ return SpanListenerResult.builder().shouldPersist(false).build();
+ }
+
+ final String serviceName = resourceAttributes.getOrDefault("service.name", "");
+ final String serviceVersion = resourceAttributes.getOrDefault("service.version", "");
+ final String deviceModel = getDeviceModel(span, resourceAttributes);
+ final String osVersion = getOsVersion(span, resourceAttributes);
+ final long timestamp = span.endTimeNanos() / 1_000_000;
+
+ final ImmutableMap<String, String> labels = ImmutableMap.of(
+ "service_name", serviceName,
+ "service_instance_id", serviceVersion,
+ "device_model", deviceModel,
+ "os_version", osVersion
+ );
+
+ // Build sample families from span attributes
+ final Map<String, SampleFamily> sampleFamilies = new HashMap<>();
+ addSample(sampleFamilies, "metrickit_app_launch_time", span, "metrickit.app_launch.time_to_first_draw_average", labels, timestamp, 1000.0);
+ addSample(sampleFamilies, "metrickit_hang_time", span, "metrickit.app_responsiveness.hang_time_average", labels, timestamp, 1000.0);
+ addSample(sampleFamilies, "metrickit_cpu_time", span, "metrickit.cpu.cpu_time", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_gpu_time", span, "metrickit.gpu.time", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_peak_memory", span, "metrickit.memory.peak_memory_usage", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_disk_write", span, "metrickit.diskio.logical_write_count", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_wifi_download", span, "metrickit.network_transfer.wifi_download", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_wifi_upload", span, "metrickit.network_transfer.wifi_upload", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_cellular_download", span, "metrickit.network_transfer.cellular_download", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_cellular_upload", span, "metrickit.network_transfer.cellular_upload", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_abnormal_exit_count", span, "metrickit.app_exit.foreground.abnormal_exit_count", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_normal_exit_count", span, "metrickit.app_exit.foreground.normal_app_exit_count", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_oom_kill_count", span, "metrickit.app_exit.background.memory_pressure_exit_count", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_scroll_hitch_ratio", span, "metrickit.animation.scroll_hitch_time_ratio", labels, timestamp, 1.0);
+
+ if (!sampleFamilies.isEmpty()) {
+ final ImmutableMap<String, SampleFamily> immutableSamples = ImmutableMap.copyOf(sampleFamilies);
+ converters.forEach(convert -> convert.toMeter(immutableSamples));
+ }
+
+ // Do NOT persist as trace — 24-hour span is not meaningful in trace view
+ return SpanListenerResult.builder().shouldPersist(false).build();
+ }
+
+ private void addSample(final Map<String, SampleFamily> families,
+ final String metricName,
+ final OTLPSpanReader span,
+ final String attrKey,
+ final ImmutableMap<String, String> labels,
+ final long timestamp,
+ final double multiplier) {
+ final String rawValue = span.getAttribute(attrKey);
+ if (rawValue == null || rawValue.isEmpty()) {
+ return;
+ }
+ try {
+ final double value = Double.parseDouble(rawValue) * multiplier;
+ final Sample sample = Sample.builder()
+ .name(metricName)
+ .labels(labels)
+ .value(value)
+ .timestamp(timestamp)
+ .build();
+ families.put(metricName, SampleFamilyBuilder.newBuilder(sample).build());
+ } catch (NumberFormatException e) {
+ log.debug("Failed to parse MetricKit attribute {}: {}", attrKey, rawValue);
+ }
+ }
+
+ private static String getDeviceModel(final OTLPSpanReader span,
+ final Map<String, String> resourceAttributes) {
+ // Prefer span-level metadata (from MetricKit payload) over resource attribute
+ final String fromSpan = span.getAttribute("metrickit.metadata.device_type");
+ if (fromSpan != null && !fromSpan.isEmpty()) {
+ return fromSpan;
+ }
+ return resourceAttributes.getOrDefault("device.model.identifier", "unknown");
+ }
+
+ private static String getOsVersion(final OTLPSpanReader span,
+ final Map<String, String> resourceAttributes) {
+ final String fromSpan = span.getAttribute("metrickit.metadata.os_version");
+ if (fromSpan != null && !fromSpan.isEmpty()) {
+ return fromSpan;
+ }
+ return resourceAttributes.getOrDefault("os.version", "unknown");
+ }
+}
diff --git a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener b/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
new file mode 100644
index 0000000000..b18e6ee2d9
--- /dev/null
+++ b/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
@@ -0,0 +1,18 @@
+#
+# 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.
+#
+org.apache.skywalking.oap.analyzer.ios.listener.IOSLayerSpanListener
+org.apache.skywalking.oap.analyzer.ios.listener.IOSMetricKitSpanListener
diff --git a/oap-server/analyzer/pom.xml b/oap-server/analyzer/pom.xml
index d521ba4a4d..df3070a417 100644
--- a/oap-server/analyzer/pom.xml
+++ b/oap-server/analyzer/pom.xml
@@ -35,6 +35,7 @@
<module>log-analyzer</module>
<module>hierarchy</module>
<module>gen-ai-analyzer</module>
+ <module>ios-analyzer</module>
<module>meter-analyzer-scripts-test</module>
</modules>
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/Layer.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/Layer.java
index 54076b72d5..d4edc1b523 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/Layer.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/Layer.java
@@ -278,7 +278,12 @@ public enum Layer {
* Envoy AI Gateway is an AI/LLM traffic gateway built on Envoy Proxy,
* providing observability for GenAI API traffic.
*/
- ENVOY_AI_GATEWAY(46, true);
+ ENVOY_AI_GATEWAY(46, true),
+
+ /**
+ * iOS/iPadOS app monitoring via OpenTelemetry Swift SDK
+ */
+ IOS(47, true);
private final int value;
/**
diff --git a/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml b/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml
new file mode 100644
index 0000000000..f0ccc976ca
--- /dev/null
+++ b/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml
@@ -0,0 +1,51 @@
+# 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.
+
+# iOS MetricKit diagnostic log processing.
+# Processes crash, hang, CPU exception, disk write exception, and app launch
+# diagnostics from the OTel Swift SDK's MetricKit instrumentation.
+#
+# These arrive as OTLP log records with InstrumentationScope "MetricKit"
+# and the diagnostic type identified by the "name" attribute.
+
+rules:
+ - name: ios-metrickit-diagnostics
+ layer: auto
+ dsl: |
+ filter {
+ if (sourceAttribute("os.name") != "iOS" && sourceAttribute("os.name") != "iPadOS") {
+ abort {}
+ }
+ if (tag("name") == null || !tag("name").startsWith("metrickit.diagnostic.")) {
+ abort {}
+ }
+
+ extractor {
+ layer "IOS"
+ instance sourceAttribute("service.version")
+
+ tag 'device.model': sourceAttribute("device.model.identifier")
+ tag 'os.version': sourceAttribute("os.version")
+ tag 'diagnosticType': tag("name")
+ tag 'exception.type': tag("exception.type")
+ tag 'exception.message': tag("exception.message")
+ tag 'exception.stacktrace': tag("exception.stacktrace")
+ tag 'signal.name': tag("metrickit.diagnostic.crash.exception.signal.name")
+ tag 'hang.duration': tag("metrickit.diagnostic.hang.hang_duration")
+ }
+
+ sink {
+ }
+ }
diff --git a/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit.yaml b/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit.yaml
new file mode 100644
index 0000000000..a9d48d4539
--- /dev/null
+++ b/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit.yaml
@@ -0,0 +1,64 @@
+# 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.
+
+# iOS MetricKit daily metrics from Apple's MetricKit framework.
+# Data arrives as OTLP spans (not metrics) via IOSMetricKitSpanListener,
+# which converts span attributes to labeled SampleFamily samples.
+
+expSuffix: service(['service_name'], Layer.IOS).instance(['service_name', 'service_instance_id'])
+metricPrefix: meter_ios
+
+metricsRules:
+ # App responsiveness — average across devices
+ - name: app_launch_time
+ exp: metrickit_app_launch_time.avg(['service_name', 'service_instance_id'])
+ - name: app_launch_time_by_device
+ exp: metrickit_app_launch_time.avg(['service_name', 'service_instance_id', 'device_model'])
+ - name: hang_time
+ exp: metrickit_hang_time.avg(['service_name', 'service_instance_id'])
+
+ # Stability — sum across devices
+ - name: abnormal_exit_count
+ exp: metrickit_abnormal_exit_count.sum(['service_name', 'service_instance_id'])
+ - name: abnormal_exit_count_by_os
+ exp: metrickit_abnormal_exit_count.sum(['service_name', 'service_instance_id', 'os_version'])
+ - name: normal_exit_count
+ exp: metrickit_normal_exit_count.sum(['service_name', 'service_instance_id'])
+ - name: oom_kill_count
+ exp: metrickit_oom_kill_count.sum(['service_name', 'service_instance_id'])
+
+ # Resource usage
+ - name: peak_memory
+ exp: metrickit_peak_memory.max(['service_name', 'service_instance_id'])
+ - name: cpu_time
+ exp: metrickit_cpu_time.sum(['service_name', 'service_instance_id'])
+ - name: gpu_time
+ exp: metrickit_gpu_time.sum(['service_name', 'service_instance_id'])
+ - name: disk_write
+ exp: metrickit_disk_write.sum(['service_name', 'service_instance_id'])
+
+ # Network transfer — sum across devices
+ - name: wifi_download
+ exp: metrickit_wifi_download.sum(['service_name', 'service_instance_id'])
+ - name: wifi_upload
+ exp: metrickit_wifi_upload.sum(['service_name', 'service_instance_id'])
+ - name: cellular_download
+ exp: metrickit_cellular_download.sum(['service_name', 'service_instance_id'])
+ - name: cellular_upload
+ exp: metrickit_cellular_upload.sum(['service_name', 'service_instance_id'])
+
+ # UI quality — average across devices
+ - name: scroll_hitch_ratio
+ exp: metrickit_scroll_hitch_ratio.avg(['service_name', 'service_instance_id'])
(skywalking) 02/02: Add iOS/iPadOS app monitoring via OpenTelemetry Swift SDK (SWIP-11)
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch feature/swip11-ios-monitoring
in repository https://gitbox.apache.org/repos/asf/skywalking.git
commit e299841ff890240ad3dcd8d934ea6d3c1c25ef5c
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat Apr 18 07:57:22 2026 +0800
Add iOS/iPadOS app monitoring via OpenTelemetry Swift SDK (SWIP-11)
Introduces IOS layer with two SpanListeners:
- IOSHTTPSpanListener: outbound HTTP (NSURLSession) client metrics
via OAL (service_cpm, endpoint_cpm, etc.). Supports OTel Swift
.old/.stable/.httpDup semconv modes via stable-then-legacy
attribute fallback (server.address -> net.peer.name, etc.).
- IOSMetricKitSpanListener: daily MetricKit stats (app launch time,
hang time, CPU/GPU/memory, network transfer, exit counts split by
foreground/background, OOM kills). Histogram percentiles use a
finite 30s overflow ceiling and emit per-bucket counts with the
MILLISECONDS unit override so MAL percentile math stays accurate.
Ships LAL rule for MetricKit crash/hang diagnostic logs, MAL rules
for service- and instance-level aggregation, iOS Root/Service/
Instance/Endpoint dashboards, Mobile menu entry, user-setup docs,
SWIP-11 design doc, and an e2e test.
Bug fixes wrapped in:
- LAL 'layer: auto' mode dropped logs after the extractor decided
the layer; codegen now propagates 'layer "..."' assignments to
LogMetadata.layer so FilterSpec.doSink sees the script-decided
value.
- Stable-semconv fallback keeps IOSHTTPSpanListener working across
all three OTel Swift URLSessionInstrumentation modes.
UI submodule bump (apache/skywalking-booster-ui@a6be0e0):
- Mobile menu icon + i18n labels (en/es/zh) for the iOS layer.
- Fix metric label rendering in multi-expression dashboard widgets.
---
.claude/skills/package/SKILL.md | 113 ++++++
.claude/skills/run-e2e/SKILL.md | 63 +++
.github/workflows/skywalking.yaml | 2 +
CLAUDE.md | 1 +
docs/en/changes/changes.md | 6 +
docs/en/security/README.md | 16 +-
docs/en/setup/backend/backend-ios-monitoring.md | 161 ++++++++
docs/en/swip/SWIP-11.md | 253 +++++-------
docs/menu.yml | 4 +
oap-server/analyzer/ios-analyzer/pom.xml | 2 +-
.../analyzer/ios/listener/IOSHTTPSpanListener.java | 202 ++++++++++
.../ios/listener/IOSLayerSpanListener.java | 85 ----
.../ios/listener/IOSMetricKitSpanListener.java | 141 +++++--
...e.skywalking.oap.server.core.trace.SpanListener | 2 +-
.../log/analyzer/v2/compiler/LALBlockCodegen.java | 9 +
.../provider/log/listener/RecordSinkListener.java | 24 +-
.../lal/test-lal/oap-cases/ios-metrickit.data.yaml | 86 ++++
.../lal/test-lal/oap-cases}/ios-metrickit.yaml | 4 +-
.../ui/template/UITemplateInitializer.java | 1 +
.../otlp/OpenTelemetryMetricRequestProcessor.java | 12 +
oap-server/server-starter/pom.xml | 6 +
.../src/main/resources/lal/ios-metrickit.yaml | 4 +-
.../otel-rules/ios/ios-metrickit-instance.yaml | 55 +++
.../resources/otel-rules/ios/ios-metrickit.yaml | 59 ++-
.../ui-initialized-templates/ios/ios-endpoint.json | 150 +++++++
.../ui-initialized-templates/ios/ios-instance.json | 348 ++++++++++++++++
.../ui-initialized-templates/ios/ios-root.json | 75 ++++
.../ui-initialized-templates/ios/ios-service.json | 446 +++++++++++++++++++++
.../resources/ui-initialized-templates/menu.yaml | 10 +
skywalking-ui | 2 +-
.../e2e-v2/cases/ios/docker-compose.yml | 30 +-
test/e2e-v2/cases/ios/e2e.yaml | 74 ++++
.../e2e-v2/cases/ios/expected/endpoint.yml | 9 +-
test/e2e-v2/cases/ios/expected/logs.yml | 45 +++
.../cases/ios/expected/metrics-has-value-label.yml | 24 +-
.../cases/ios/expected/metrics-has-value.yml | 20 +-
.../e2e-v2/cases/ios/expected/service.yml | 14 +-
37 files changed, 2220 insertions(+), 338 deletions(-)
diff --git a/.claude/skills/package/SKILL.md b/.claude/skills/package/SKILL.md
new file mode 100644
index 0000000000..a0ce1baed1
--- /dev/null
+++ b/.claude/skills/package/SKILL.md
@@ -0,0 +1,113 @@
+---
+name: package
+description: Rebuild the SkyWalking distribution and OAP Docker image after source changes. Use before running e2e tests so the image reflects your code changes. Avoids the "image looks updated but runtime has stale jars" trap.
+argument-hint: "[oap|all|dist-only]"
+---
+
+# Package OAP Distribution & Docker Image
+
+Rebuilding the Docker image after a source-code change has a **two-step dependency**: rebuild the dist tarball, then rebuild the image. Skipping the first step silently produces an image with stale jars — the Docker build still "succeeds," the image has a fresh timestamp, but the embedded jar is the one from your previous build.
+
+## Why this matters
+
+`make docker.oap` in the root `Makefile` is defined as:
+
+```
+docker.% push.docker.%: $(CONTEXT)/$(DIST) $(SW_ROOT)/docker/%/*
+ $(DOCKER_RULE)
+```
+
+It depends on `$(CONTEXT)/$(DIST)` (the dist tarball) **as a file prerequisite**. If that tarball already exists, make does not regenerate it — it just copies whatever is on disk into `dist/docker_build/oap/` and runs `docker buildx build`. There is **no** dependency that rebuilds the tarball from source.
+
+Only `make docker` triggers the full chain (`init → build.all → docker.all`). So:
+
+| Command | Rebuilds source? | Rebuilds tarball? | Rebuilds image? |
+|---------|:--:|:--:|:--:|
+| `./mvnw -pl <module> package` | only that module | no | no |
+| `./mvnw -pl apm-dist -am package -Pbackend,dist` | all backend deps + dist | yes | no |
+| `make docker.oap` | **no** | **no** (uses whatever's on disk) | yes |
+| `make docker` | yes (`build.all`) | yes | yes (`docker.all`) |
+
+## `flatten:flatten` — always run it before `package`/`install`
+
+SkyWalking's poms use `${revision}` as a placeholder version (e.g., `10.5.0-SNAPSHOT`). The `flatten-maven-plugin` resolves `${revision}` into concrete versions and writes a `.flattened-pom.xml`. Without it:
+
+- Installed artifacts carry the literal string `${revision}` in their coordinates and cannot be resolved as dependencies.
+- Downstream modules (including `apm-dist`) see an inconsistent dependency graph.
+- Symptoms are subtle: `-pl <module> -am` may succeed in isolation but fail or pull in stale transitive artifacts when the same module is consumed by another build invocation in the same session.
+
+**Always run `flatten:flatten` in the same goal chain as `package` or `install`.** The CI `dist-tar` job and the `compile` skill both do this. Example:
+
+```bash
+./mvnw clean flatten:flatten package -Pbackend,dist -DskipTests
+```
+
+Not optional — treat it as part of `package`.
+
+## Pick the right command
+
+### Changed OAP source → want a new image
+
+```bash
+# Recommended: full chain
+make docker
+```
+
+or, faster and equivalent for backend-only changes (skips UI):
+
+```bash
+./mvnw -pl apm-dist -am -o clean flatten:flatten package -Pbackend,dist -DskipTests -Dcheckstyle.skip=true -Dmaven.javadoc.skip=true
+make docker.oap
+```
+
+**Do not** just run `make docker.oap` after a code edit — the image will look rebuilt but your change will not be in the jars.
+**Do not** skip `flatten:flatten` — see the section above.
+
+### Changed only the dist packaging (e.g., `apm-dist/`, log4j2.xml)
+
+```bash
+./mvnw -pl apm-dist -am -o clean flatten:flatten package -Pbackend,dist -DskipTests
+make docker.oap
+```
+
+### Changed only Dockerfile / entrypoint (`docker/oap/*`)
+
+The tarball is untouched, so `make docker.oap` alone is correct:
+
+```bash
+make docker.oap
+```
+
+## Verify the fix reached the image
+
+Docker's "image created" timestamp updates even when the content is stale (because `--no-cache` is used). **Don't trust the timestamp.** Verify the jar contents directly:
+
+```bash
+# Copy the jar out of the container
+docker cp <container-name>:/skywalking/oap-libs/<module>-<version>.jar /tmp/verify.jar
+
+# Extract the specific class
+cd /tmp && jar -xf verify.jar <path/to/YourClass.class>
+
+# Grep for a string your fix introduced (unique literal, method name, etc.)
+grep -oa "myFixSignature" <path/to/YourClass.class>
+```
+
+If grep finds nothing, the image does not contain your fix — you forgot to rebuild the dist. Re-run with the correct chain.
+
+## Common pitfalls
+
+- **`make docker.oap` after `./mvnw package`**: The module jar is fresh in `oap-server/.../target/`, but the dist tarball at `dist/apache-skywalking-apm-bin.tar.gz` is untouched. Image uses the old jar.
+- **Cancelling a `make docker` mid-flight**: leaves the dist half-rebuilt. Re-run `make docker` or delete `dist/` first.
+- **Stale buildx cache**: not usually the issue (the Makefile passes `--no-cache`), but if you suspect it, run `docker buildx prune`.
+- **Trusting image timestamps**: `docker images` shows when the image was *tagged*, not when its content actually changed. A no-op rebuild still updates the timestamp.
+- **Interactive buildx hangs**: if `docker buildx build` sits silent for minutes with no stdout, kill it and retry. The buildx container driver occasionally stalls; a restart is faster than waiting.
+
+## The golden rule
+
+After any Java source edit that affects code shipped in `oap-libs/`, run one of:
+
+1. `make docker` (full, safest)
+2. `./mvnw -pl apm-dist -am -o package -Pbackend,dist -DskipTests -Dcheckstyle.skip=true && make docker.oap` (fast)
+
+Then **verify the fix is in the image's jar** before running e2e. Don't assume.
diff --git a/.claude/skills/run-e2e/SKILL.md b/.claude/skills/run-e2e/SKILL.md
index 6f6999de85..ecf3b24275 100644
--- a/.claude/skills/run-e2e/SKILL.md
+++ b/.claude/skills/run-e2e/SKILL.md
@@ -134,6 +134,69 @@ Do NOT run cleanup immediately. Instead:
e2e cleanup -c test/e2e-v2/cases/<case-path>/e2e.yaml
```
+### 6. Manually fire each verify query (fast triage)
+
+The `e2e verify` retry loop runs in sequence and stops at the first failing case, so a single bad query hides every case after it. When a verify fails, **run each verify case directly against the still-running OAP** before editing anything — you'll see the real error (bad flag, missing data, wrong expected), not the progress spinner. This is also the right way to author new verify cases: craft the query against live OAP, confirm the actual YAML, then write the expected file.
+
+```bash
+# Find the host-side port that infra-e2e bound to OAP's container port 12800.
+# (Each run picks a new random port; the trigger log prints it too.)
+docker ps --filter "name=skywalking_e2e-oap" --format "{{.Ports}}" \
+ | grep -oE "[0-9]+->12800" | head -1
+# => e.g. 56381->12800
+
+URL=http://localhost:56381/graphql
+SWCTL=/tmp/skywalking-infra-e2e/bin/swctl
+
+# Copy the query from e2e.yaml verbatim, then substitute ${oap_host} → localhost
+# and ${oap_12800} → the port you just found:
+$SWCTL --display yaml --base-url=$URL service ly IOS
+$SWCTL --display yaml --base-url=$URL logs list --service-name=MyiOSApp
+$SWCTL --display yaml --base-url=$URL metrics exec --expression=service_cpm --service-name=MyiOSApp
+```
+
+When a `swctl` subcommand rejects a flag (`Incorrect Usage: flag provided but not defined: -layer`), the e2e config is using syntax the pinned `swctl` commit doesn't support. Find the right syntax with `swctl <cmd> --help` and update the e2e config. Common cases encountered:
+
+| Broken flag/form | Working form |
+|---|---|
+| `service ls --layer IOS` | `service ly IOS` |
+| `metrics exec ... --is-normal=true` | drop `--is-normal` (default behavior) |
+
+For queries that *don't* use `swctl` (raw `curl` against `/loki/...`, Zipkin, PromQL), hit the matching exposed port:
+
+```bash
+curl "http://localhost:$(docker ps --filter name=skywalking_e2e-oap --format '{{.Ports}}' | grep -oE '[0-9]+->3100' | head -1 | cut -d'-' -f1)/loki/api/v1/labels"
+```
+
+### 7. UI template changes require a fresh DB
+
+`UITemplateInitializer.initTemplate()` (in `oap-server/server-core`) calls `uiTemplateManagementService.addIfNotExist(setting)` — keyed by the `id` field in each `ui-initialized-templates/**/*.json`. Same ID → skipped. So edits to an *existing* template JSON (adding widgets, relabeling, changing expressions) will **not** be applied on an already-initialized OAP, even after a container restart, because the old copy still lives in storage.
+
+To pick up dashboard JSON changes:
+
+```bash
+# Remove both containers — BanyanDB stores state inside the container FS in the
+# e2e compose (no named volume), so removing the container wipes state cleanly.
+docker rm -f skywalking_e2e-oap-1 skywalking_e2e-banyandb-1
+
+# For compose setups that use a named volume, also:
+# docker volume rm <volume-name>
+
+# Then re-run — OAP sees empty storage, loads the new template JSON.
+e2e run -c test/e2e-v2/cases/<case>/e2e.yaml
+```
+
+Symptom to watch for: you edit the JSON, rebuild, redeploy — dashboard in the UI still shows the pre-edit layout. That's not a caching bug; that's `addIfNotExist` doing exactly what its name says.
+
+### 8. Author the expected YAML from live output
+
+For a new verify case, the workflow is:
+
+1. Fire the query manually (see step 6) and capture the YAML.
+2. Pick which fields are *meaningful domain values* (must match exactly) vs *dynamic runtime values* (`notEmpty` / `gt` / `ge`). See `test/e2e-v2/CLAUDE.md` for the decision guide.
+3. Write the expected file. If the response is a list, wrap the items in `{{- contains . }} ... {{- end }}` so ordering and extra actual items don't fail the match.
+4. Re-run `e2e verify` alone (the containers are still up from the previous run); iterate on the expected file without rebuilding.
+
## Common test cases
| Shorthand | Path |
diff --git a/.github/workflows/skywalking.yaml b/.github/workflows/skywalking.yaml
index 0786f28023..fc13e818d8 100644
--- a/.github/workflows/skywalking.yaml
+++ b/.github/workflows/skywalking.yaml
@@ -639,6 +639,8 @@ jobs:
config: test/e2e-v2/cases/otlp-virtual-genai/e2e.yaml
- name: Zipkin Virtual GenAI
config: test/e2e-v2/cases/zipkin-virtual-genai/e2e.yaml
+ - name: iOS Monitoring
+ config: test/e2e-v2/cases/ios/e2e.yaml
- name: Nginx
config: test/e2e-v2/cases/nginx/e2e.yaml
diff --git a/CLAUDE.md b/CLAUDE.md
index fef61dd127..e1586e5060 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -246,6 +246,7 @@ Actions owned by `actions/*` (GitHub), `github/*`, and `apache/*` are always all
1. **Always check submodules**: Protocol changes may require submodule updates
2. **Generate sources first**: Run `mvnw compile` before analyzing generated code
3. **Install package**: Use `mvnw flatten:flatten install` to build the precompiler and export generated classes before running tests. ref to [compile skill doc](.claude/skills/compile/SKILL.md)
+3. **Full rebuild on cross-module changes**: If you changed more than two modules or pulled/rebased code from git remote, run `mvnw clean install` (or `mvnw clean package`) on the **whole project** rather than picking individual modules with `-pl`. Incremental `-pl ... -am` builds can leave stale jars in `.m2` or `oap-libs/` when jar sizes don't change but content does, causing hard-to-debug runtime issues.
3. **Respect checkstyle**: No System.out, no @author, no Chinese characters
4. **Follow module patterns**: Use existing modules as templates
5. **Check multiple storage implementations**: Logic may vary by storage type
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index 36cd8bc134..742d883458 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -29,11 +29,17 @@
* LAL: add `layer: auto` mode for dynamic layer assignment when `service.layer` is absent.
* Add two-phase `SpanListener` SPI mechanism for extensible trace span processing. Refactor GenAI from hardcoded `SpanForward.processGenAILogic()` to `GenAISpanListener`.
* Add OTLP/HTTP receiver support for traces, logs, and metrics (`/v1/traces`, `/v1/logs`, `/v1/metrics`). Supports both `application/x-protobuf` and `application/json` content types.
+* Add iOS/iPadOS app monitoring via OpenTelemetry Swift SDK (SWIP-11). Includes the `IOS` layer, `IOSHTTPSpanListener` for outbound HTTP client metrics (supports OTel Swift `.old`/`.stable`/`.httpDup` semantic-convention modes via stable-then-legacy attribute fallback), `IOSMetricKitSpanListener` for daily MetricKit metrics (exit counts split by foreground/background, app-launch / hang-time percentile histograms with finite 30 s overflow ceiling), LAL rules for crash/hang diagnostics, Mo [...]
+* Fix LAL `layer: auto` mode dropping logs after extractor set the layer. Codegen now propagates `layer "..."` assignments to `LogMetadata.layer` so `FilterSpec.doSink()` sees the script-decided layer.
+* Fix MetricKit histogram percentile metrics being reported at 1000× their true value — the listener now marks its `SampleFamily` with `defaultHistogramBucketUnit(MILLISECONDS)` so MAL's default SECONDS→MS rescale of `le` labels is not applied.
#### UI
+* Add mobile menu icon and i18n labels for the iOS layer.
+* Fix metric label rendering in multi-expression dashboard widgets.
#### Documentation
* Update LAL documentation with `sourceAttribute()` function and `layer: auto` mode.
+* Add iOS app monitoring setup documentation.
All issues and pull requests are [here](https://github.com/apache/skywalking/issues?q=milestone:10.5.0)
diff --git a/docs/en/security/README.md b/docs/en/security/README.md
index 6cb3aa4883..f2c91994b2 100644
--- a/docs/en/security/README.md
+++ b/docs/en/security/README.md
@@ -24,7 +24,15 @@ Remote Code Execution (RCE) issues.
For some sensitive environment, consider to limit the telemetry report frequency in case of DoS/DDoS for exposed OAP
and UI services.
-## appendix
-
-The SkyWalking [client-js](https://github.com/apache/skywalking-client-js) agent is always running out of the secured
-environment. Please follow its **security notice** for more details.
+## Client-Side Monitoring
+
+Client-side applications — iOS/iPadOS apps (via OpenTelemetry Swift SDK), browser web apps
+(via [client-js](https://github.com/apache/skywalking-client-js)), and WeChat/Alipay
+mini-programs (via [mini-program-monitor](https://github.com/SkyAPM/mini-program-monitor)) —
+send telemetry data **from the public internet** to OAP endpoints including OTLP/HTTP
+(`/v1/traces`, `/v1/logs`, `/v1/metrics`), SkyWalking native (`/v3/segments`), and browser
+reporting endpoints.
+
+These endpoints accept data from any client without authentication by default. Apply the
+security policies listed above, especially rate limiting, to prevent abuse from untrusted
+client-side sources.
diff --git a/docs/en/setup/backend/backend-ios-monitoring.md b/docs/en/setup/backend/backend-ios-monitoring.md
new file mode 100644
index 0000000000..d2a9808945
--- /dev/null
+++ b/docs/en/setup/backend/backend-ios-monitoring.md
@@ -0,0 +1,161 @@
+# iOS App Monitoring
+
+SkyWalking supports iOS/iPadOS app monitoring via the [OpenTelemetry Swift SDK](https://github.com/open-telemetry/opentelemetry-swift).
+Three data streams are captured:
+
+1. **Outbound HTTP traffic** — per-request client-side metrics for HTTP requests from the iOS app to remote servers (latency, error rate, per-domain breakdown)
+2. **MetricKit daily stats** — app launch time percentile, hang time percentile/sum, crash count, OOM kills, memory, network transfer (once per day per device)
+3. **MetricKit diagnostic logs** — crash and hang reports with native stack traces
+
+All data is sent via standard OTLP/HTTP to SkyWalking OAP. No OTel Collector is required,
+though one can be used for buffering.
+
+## OAP Configuration
+
+Enable the OTLP receiver with trace and log handlers, the iOS MAL rules, and the LAL rules
+in `application.yml`:
+
+```yaml
+receiver-otel:
+ selector: default
+ default:
+ enabledHandlers: otlp-traces,otlp-logs
+ enabledOtelMetricsRules: ios/ios-metrickit,ios/ios-metrickit-instance
+
+log-analyzer:
+ selector: default
+ default:
+ lalFiles: ios-metrickit
+```
+
+OTLP/HTTP endpoints are available on the core REST port (default 12800)
+at `/v1/traces`, `/v1/logs`, and `/v1/metrics`.
+
+## iOS App Setup
+
+```swift
+import OpenTelemetryApi
+import OpenTelemetrySdk
+import OpenTelemetryProtocolExporterHTTP
+import ResourceExtension
+import URLSessionInstrumentation
+
+// Resource attributes (device, OS, app info — auto-collected)
+let resources = DefaultResources().get()
+
+// OTLP exporter — point to OAP's REST port (default 12800)
+// In production, use an OTel Collector or API gateway in front of OAP
+let oapEndpoint = "https://<oap-or-collector-host>/v1"
+let traceExporter = OtlpHttpTraceExporter(
+ endpoint: URL(string: "\(oapEndpoint)/traces")!
+)
+let logExporter = OtlpHttpLogExporter(
+ endpoint: URL(string: "\(oapEndpoint)/logs")!
+)
+
+// TracerProvider
+let tracerProvider = TracerProviderBuilder()
+ .add(spanProcessor: BatchSpanProcessor(spanExporter: traceExporter))
+ .with(resource: resources)
+ .build()
+OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider)
+
+// LoggerProvider (for MetricKit diagnostics)
+let loggerProvider = LoggerProviderBuilder()
+ .with(resource: resources)
+ .with(processors: [SimpleLogRecordProcessor(logRecordExporter: logExporter)])
+ .build()
+OpenTelemetry.registerLoggerProvider(loggerProvider: loggerProvider)
+
+// Auto-instrument URLSession (exclude collector URL to avoid feedback loop)
+let config = URLSessionInstrumentationConfiguration(
+ shouldInstrument: { request in
+ return request.url?.host != "<oap-or-collector-host>"
+ }
+)
+let _ = URLSessionInstrumentation(configuration: config)
+```
+
+> **Important:** The `shouldInstrument` callback is critical. Without it, the URLSession
+> auto-instrumentation captures OTLP export calls themselves, creating an exponential
+> feedback loop.
+
+> **Security:** iOS apps send telemetry from the public internet. See
+> [Security Notice](../../security/README.md) for deployment guidelines.
+
+## Outbound HTTP Traffic
+
+The `IOSHTTPSpanListener` detects iOS HTTP spans (resource attribute `os.name` is `iOS` or
+`iPadOS`, span scope `NSURLSession`, kind `CLIENT`) and emits OAL sources (`Service`,
+`ServiceInstance`, `Endpoint`) with `Layer.IOS`. This produces **client-side outbound traffic
+metrics** — these measure HTTP requests **from the iOS app to remote servers**, not server-side
+processing.
+
+The listener reads HTTP attributes with stable-semconv fallback, so all three OpenTelemetry
+Swift URLSession instrumentation modes work:
+
+| Attribute | Stable key (preferred) | Legacy key (fallback) |
+|-------------|-------------------------------|-----------------------|
+| Host | `server.address` | `net.peer.name` |
+| Method | `http.request.method` | `http.method` |
+| Status code | `http.response.status_code` | `http.status_code` |
+
+- **Service** = the iOS app (from `service.name`)
+- **Instance** = the app version (from `service.version`)
+- **Endpoint** = the remote domain being called (e.g., `api.example.com`)
+
+The existing `core.oal` rules automatically produce standard outbound traffic metrics under the IOS layer:
+`service_cpm`, `service_resp_time`, `service_sla`, `service_percentile`,
+`service_instance_cpm`, `service_instance_resp_time`,
+`endpoint_cpm`, `endpoint_resp_time`, `endpoint_sla`, `endpoint_percentile`.
+
+## MetricKit Metrics
+
+The `IOSMetricKitSpanListener` intercepts MetricKit payload spans (scope `MetricKit`,
+span name `MXMetricPayload`) and extracts daily device statistics. It pushes `SampleFamily`
+samples into the shared MAL pipeline via `OpenTelemetryMetricRequestProcessor.toMeter()`.
+These spans are **not persisted as traces** (24-hour duration is not meaningful in trace view).
+
+Available metrics (prefixed with `meter_ios_`):
+
+| Metric | Aggregation | Description |
+|------------------------------------|----------------------|-------------------------------------------------------------------------------------------------------|
+| `app_launch_time_percentile` | histogram_percentile | App launch time (time to first draw) P50-P99. Bucket ceiling 30 s (finite overflow sentinel). |
+| `hang_time_percentile` | histogram_percentile | UI hang (freeze) duration P50-P99. Bucket ceiling 30 s (finite overflow sentinel). |
+| `hang_time_sum` | sum | Total hang time across devices |
+| `foreground_abnormal_exit_count` | sum | Abnormal (crash) exits while app was in foreground |
+| `background_abnormal_exit_count` | sum | Abnormal exits while app was in background (watchdog kills, background-task crashes) |
+| `background_oom_kill_count` | sum | Out-of-memory kills (background memory-pressure exits) |
+| `peak_memory` | max | Peak memory usage (worst-case device) |
+| `cpu_time` | sum | Cumulative CPU time |
+| `gpu_time` | sum | Cumulative GPU time |
+| `disk_write` | sum | Disk write volume |
+| `wifi_download` | avg | Average per-device WiFi download |
+| `wifi_upload` | avg | Average per-device WiFi upload |
+| `cellular_download` | avg | Average per-device cellular download |
+| `cellular_upload` | avg | Average per-device cellular upload |
+| `scroll_hitch_ratio` | avg | Average scroll jank ratio |
+
+Normal-exit counts (`metrickit.app_exit.foreground.normal_app_exit_count`,
+`metrickit.app_exit.background.normal_app_exit_count`) are intentionally **not** exposed — graceful
+exits carry no diagnostic signal and would only crowd dashboards.
+
+## MetricKit Diagnostic Logs
+
+Crash and hang diagnostics from MetricKit arrive as OTLP log records and are processed by
+the `ios-metrickit` LAL rule. The rule uses `layer: auto` mode with `sourceAttribute("os.name")`
+to detect iOS logs and set the `IOS` layer.
+
+Diagnostic types:
+- `metrickit.diagnostic.crash` — crash reports with exception type, signal, and stack trace
+- `metrickit.diagnostic.hang` — hang reports with duration and stack trace
+- `metrickit.diagnostic.cpu_exception` — CPU usage exceptions
+- `metrickit.diagnostic.disk_write_exception` — disk write exceptions
+- `metrickit.diagnostic.app_launch` — slow app launch diagnostics (iOS 16+)
+
+## Limitations
+
+- MetricKit data requires a **real iOS device** — not available in Simulator
+- MetricKit stats are delivered approximately once per day — not real-time
+- Screen/view transition tracking is not automatic — the OTel Swift SDK does not instrument
+ UIViewController or SwiftUI lifecycle
diff --git a/docs/en/swip/SWIP-11.md b/docs/en/swip/SWIP-11.md
index 98d1629ace..fa867ee717 100644
--- a/docs/en/swip/SWIP-11.md
+++ b/docs/en/swip/SWIP-11.md
@@ -305,8 +305,7 @@ for (io.opentelemetry.proto.trace.v1.Span span : scopeSpans.getSpansList()) {
| Listener | Detects | Extracts | Persists? | Modifies? |
|---|---|---|---|---|
| `GenAISpanListener` | `gen_ai.system` or `gen_ai.provider.name` attribute | Token metrics, cost → Sources | Yes | Yes (adds `estimated_cost` tag) |
-| `IOSMetricKitSpanListener` | `scopeName == "MetricKit"` + `span.name == "MXMetricPayload"` | Device stats → SampleFamily → MAL | **No** | No |
-| `IOSLayerSpanListener` | resource `os.name == "iOS"` or `"iPadOS"` | None (layer assignment only) | Yes | No (sets `layer = Layer.IOS`) |
+| `IOSMetricKitSpanListener` | `scopeName == "MetricKit"` + `span.name == "MXMetricPayload"` | Device stats → SampleFamily → shared MAL pipeline | **No** | No |
Listeners are registered via SPI (`META-INF/services/`) and loaded at handler initialization.
The existing `processGenAILogic()` is refactored into `GenAISpanListener` — no behavior change,
@@ -316,46 +315,32 @@ just better structure.
- Listeners see raw OTLP data — InstrumentationScope name, resource attributes as separate map
- Any listener can veto trace persistence — prevents Zipkin conversion entirely (no wasted work)
- Any listener can inject tags — merged before Zipkin conversion
-- Any listener can set layer — applies to service registration
- Multiple listeners can process the same span (e.g., a GenAI span on iOS triggers both)
- If ANY listener vetoes persistence, the span is not converted or stored
+**Note:** No `IOSLayerSpanListener` is needed. The `IOS` layer is registered automatically
+when the MAL `expSuffix` with `Layer.IOS` processes MetricKit metrics. The OTLP→Zipkin trace
+pipeline (`SpanForward`) emits Zipkin-specific sources (not OAL sources), so there are no
+OAL traffic metrics for OTLP traces.
+
### 6. Entity Model
-| SkyWalking Entity | Source | Example |
-|----------------------|-------------------------------------------------|---------------|
-| **Service** | `service.name` resource attribute | `MyApp` |
-| **Service Instance** | `service.version` resource attribute (set by LAL/listener, SDK doesn't set `service.instance.id`) | `2.1.0 (45)` |
-| **Endpoint** | HTTP span `http.target` attribute | `/api/users` |
+| SkyWalking Entity | Source | Example |
+|----------------------|------------------------------------------------------------|---------------|
+| **Service** | `service_name` label in MAL `expSuffix` | `MyApp` |
+| **Service Instance** | `service_instance_id` label in MAL `expSuffix` | `2.1.0` |
+
+No endpoint entity — MetricKit metrics are service/instance scoped only.
### 7. HTTP Span Processing (Trace Path)
HTTP spans from `InstrumentationScope NSURLSession` flow through the existing OTLP → Zipkin → SpanForward
-trace pipeline. The layer detection in Section 5 assigns `Layer.IOS` to the service.
-
-Span attributes (verified from real SDK output):
-
-| Attribute | Example | Description |
-|------------------------------|---------------------------------|--------------------------------------|
-| `http.method` | `GET` | HTTP method |
-| `http.url` | `https://api.example.com/users` | Full URL |
-| `http.target` | `/users` | URL path (used as endpoint) |
-| `http.status_code` | `200` | Response status |
-| `http.scheme` | `https` | URL scheme |
-| `net.peer.name` | `api.example.com` | Server host |
-| `net.peer.port` | `443` | Server port |
-| `http.request.body.size` | `128` | Request body bytes |
-| `http.response.body.size` | `505` | Response body bytes |
-| `network.connection.type` | `wifi`, `cell` | Network type (iOS only) |
-| `network.connection.subtype` | `LTE`, `NR` | Radio technology (iOS cellular only) |
-| `network.carrier.name` | `China Mobile` | Carrier name (iOS cellular only) |
-| `network.carrier.icc` | `CN` | Carrier country code |
-| `network.carrier.mcc` | `460` | Mobile country code |
-| `network.carrier.mnc` | `00` | Mobile network code |
-
-SkyWalking's existing OAL `core.oal` already derives standard service/endpoint metrics
-(`service_cpm`, `service_resp_time`, `service_percentile`, `endpoint_cpm`, etc.) from trace spans.
-These work automatically for iOS HTTP spans once the layer is assigned.
+trace pipeline. They are stored as Zipkin spans and queryable via the Zipkin query API.
+
+**Note:** The OTLP→Zipkin trace pipeline (`SpanForward`) emits Zipkin-specific sources
+(`ZipkinService`, `ZipkinServiceSpan`, `ZipkinServiceRelation`), not OAL sources. There are
+**no OAL traffic metrics** (e.g., `service_cpm`, `service_resp_time`) generated from OTLP traces.
+HTTP trace metrics for iOS may be added in the future via MAL extraction in a SpanListener.
#### OTLP Export Feedback Loop
@@ -373,57 +358,10 @@ URLSessionInstrumentationConfiguration(
)
```
-#### Searchable Tags
-
-Add the following to `searchableTracesTags` default configuration:
-
-| Tag | Purpose |
-|----------------------------|--------------------------------------------|
-| `device.model.identifier` | Filter by device model (iPhone15,2, etc.) |
-| `os.version` | Filter by iOS version |
-| `network.connection.type` | Filter by wifi/cell |
-| `network.carrier.name` | Filter by carrier |
-
### 8. Metrics Overview
-iOS monitoring has two distinct metrics sources with different characteristics:
-
-| Source | Granularity | Delivery | Time Bucket | Example |
-|---|---|---|---|---|
-| HTTP trace spans | Per-request, real-time | Continuous | Minute (standard) | Request latency, error rate, CPM |
-| MetricKit spans | Per-device, daily aggregate | Once/day per device | Day | App launch time, crash count, memory |
-
-#### HTTP Trace Metrics (Existing OAL, Minute Granularity)
-
-HTTP spans from URLSession flow through the standard trace pipeline. The existing `core.oal` metrics
-apply automatically — no new OAL needed:
-
-**Service scope:**
-
-| Monitoring Panel | Metric Name | OAL | Description |
-|---|---|---|---|
-| HTTP Throughput | `service_cpm` | `from(Service.*).cpm()` | Requests per minute |
-| HTTP Avg Latency | `service_resp_time` | `from(Service.latency).longAvg()` | Average response time |
-| HTTP Latency Percentile | `service_percentile` | `from(Service.latency).percentile2(10)` | P50/P75/P90/P95/P99 |
-| HTTP Success Rate | `service_sla` | `from(Service.*).percent(status == true)` | Success percentage |
-| HTTP Apdex | `service_apdex` | `from(Service.latency).apdex(name, status)` | User satisfaction score |
-
-**Instance scope (per app version):**
-
-| Monitoring Panel | Metric Name | OAL | Description |
-|---|---|---|---|
-| HTTP Throughput | `service_instance_cpm` | `from(ServiceInstance.*).cpm()` | RPM per version |
-| HTTP Avg Latency | `service_instance_resp_time` | `from(ServiceInstance.latency).longAvg()` | Avg latency per version |
-| HTTP Success Rate | `service_instance_sla` | `from(ServiceInstance.*).percent(status == true)` | Success rate per version |
-
-**Endpoint scope (per URL path):**
-
-| Monitoring Panel | Metric Name | OAL | Description |
-|---|---|---|---|
-| Endpoint Throughput | `endpoint_cpm` | `from(Endpoint.*).cpm()` | RPM per endpoint |
-| Endpoint Avg Latency | `endpoint_resp_time` | `from(Endpoint.latency).longAvg()` | Avg latency per endpoint |
-| Endpoint Latency Percentile | `endpoint_percentile` | `from(Endpoint.latency).percentile2(10)` | P50–P99 per endpoint |
-| Endpoint Success Rate | `endpoint_sla` | `from(Endpoint.*).percent(status == true)` | Success rate per endpoint |
+iOS monitoring metrics come from MetricKit — daily aggregated device statistics delivered once
+per day per device via the OTel Swift SDK's MetricKit instrumentation.
### 9. MetricKit Span Listener (`IOSMetricKitSpanListener`)
@@ -431,11 +369,15 @@ Apple's MetricKit delivers pre-aggregated app statistics once per day. The OTel
this as a single span with `startTime = 24h ago`, `endTime = now`, with all statistics as span
attributes. These are **not trace spans** — they must be intercepted and converted to metrics.
-`IOSMetricKitSpanListener` implements the `OTLPSpanListener` interface (Section 5):
-- **Detection:** `scopeName == "MetricKit"` AND `span.getName() == "MXMetricPayload"` — uses the
+`IOSMetricKitSpanListener` implements the `SpanListener` SPI (Section 5):
+- **Detection:** `scopeName == "MetricKit"` AND `span.spanName() == "MXMetricPayload"` — uses the
raw OTLP InstrumentationScope name, available because listeners run before Zipkin conversion
-- **Action:** Extract span attributes as `SampleFamily` samples with labels, feed into MAL pipeline
-- **Persistence:** Returns `persistTrace = false` — a 24-hour span must not be stored as a trace
+- **Action:** Extract span attributes as `SampleFamily` samples with 4 labels (`service_name`,
+ `service_instance_id`, `device_model`, `os_version`), push into the shared MAL pipeline via
+ `OpenTelemetryMetricRequestProcessor.toMeter()` — no duplicate rule loading
+- **Persistence:** Returns `shouldPersist = false` — a 24-hour span must not be stored as a trace
+- **Required module:** `receiver-otel` — the listener uses the otel-receiver's MAL converters
+ configured via `enabledOtelMetricsRules`
#### MetricKit Source Attributes
@@ -449,10 +391,11 @@ attributes. These are **not trace spans** — they must be intercepted and conve
| `metrickit.network_transfer.wifi_upload` | Double | bytes | WiFi upload (24h) |
| `metrickit.network_transfer.cellular_download` | Double | bytes | Cellular download (24h) |
| `metrickit.network_transfer.cellular_upload` | Double | bytes | Cellular upload (24h) |
-| `metrickit.app_exit.foreground.abnormal_exit_count` | Int | count | Abnormal exits (crashes) |
-| `metrickit.app_exit.foreground.normal_app_exit_count` | Int | count | Normal exits |
-| `metrickit.app_exit.background.abnormal_exit_count` | Int | count | Background abnormal exits |
-| `metrickit.app_exit.background.memory_pressure_exit_count` | Int | count | OOM kills |
+| `metrickit.app_exit.foreground.abnormal_exit_count` | Int | count | Abnormal exits (foreground crashes) — aggregated |
+| `metrickit.app_exit.foreground.normal_app_exit_count` | Int | count | Normal foreground exits — not aggregated |
+| `metrickit.app_exit.background.abnormal_exit_count` | Int | count | Background abnormal exits — aggregated |
+| `metrickit.app_exit.background.normal_app_exit_count` | Int | count | Normal background exits — not aggregated |
+| `metrickit.app_exit.background.memory_pressure_exit_count` | Int | count | OOM kills — aggregated |
| `metrickit.animation.scroll_hitch_time_ratio` | Double | ratio | Scroll jank ratio |
| `metrickit.gpu.time` | Double | seconds | Cumulative GPU time |
| `metrickit.diskio.logical_write_count` | Double | bytes | Disk writes (24h) |
@@ -483,7 +426,7 @@ The listener converts each `MXMetricPayload` span into labeled `SampleFamily` sa
metrickit_app_launch_time{service_name="MyApp", service_instance_id="2.1.0", device_model="iPhone15,2", os_version="17.4.1"} 850
metrickit_hang_time{service_name="MyApp", service_instance_id="2.1.0", device_model="iPhone15,2", os_version="17.4.1"} 120
metrickit_peak_memory{service_name="MyApp", service_instance_id="2.1.0", device_model="iPhone15,2", os_version="17.4.1"} 157286400
-metrickit_abnormal_exit_count{service_name="MyApp", service_instance_id="2.1.0", device_model="iPhone15,2", os_version="18.0"} 2
+metrickit_foreground_abnormal_exit_count{service_name="MyApp", service_instance_id="2.1.0", device_model="iPhone15,2", os_version="18.0"} 2
metrickit_wifi_download{service_name="MyApp", service_instance_id="2.1.0", device_model="iPhone15,2", os_version="17.4.1"} 52428800
```
@@ -498,52 +441,63 @@ Labels are extracted from:
Create `oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit.yaml`:
```yaml
-expSuffix: service(['service_name'], Layer.IOS).instance(['service_name', 'service_instance_id'])
+expSuffix: service(['service_name'], Layer.IOS)
metricPrefix: meter_ios
-# App responsiveness — average across devices
-app_launch_time:
- exp: metrickit_app_launch_time.avg(['service_name', 'service_instance_id'])
-app_launch_time_by_device:
- exp: metrickit_app_launch_time.avg(['service_name', 'service_instance_id', 'device_model'])
-hang_time:
- exp: metrickit_hang_time.avg(['service_name', 'service_instance_id'])
-
-# Stability — sum across devices
-abnormal_exit_count:
- exp: metrickit_abnormal_exit_count.sum(['service_name', 'service_instance_id'])
-abnormal_exit_count_by_os:
- exp: metrickit_abnormal_exit_count.sum(['service_name', 'service_instance_id', 'os_version'])
-normal_exit_count:
- exp: metrickit_normal_exit_count.sum(['service_name', 'service_instance_id'])
-oom_kill_count:
- exp: metrickit_oom_kill_count.sum(['service_name', 'service_instance_id'])
-
-# Resource usage
-peak_memory:
- exp: metrickit_peak_memory.max(['service_name', 'service_instance_id'])
-cpu_time:
- exp: metrickit_cpu_time.sum(['service_name', 'service_instance_id'])
-gpu_time:
- exp: metrickit_gpu_time.sum(['service_name', 'service_instance_id'])
-disk_write:
- exp: metrickit_disk_write.sum(['service_name', 'service_instance_id'])
-
-# Network transfer — sum across devices
-wifi_download:
- exp: metrickit_wifi_download.sum(['service_name', 'service_instance_id'])
-wifi_upload:
- exp: metrickit_wifi_upload.sum(['service_name', 'service_instance_id'])
-cellular_download:
- exp: metrickit_cellular_download.sum(['service_name', 'service_instance_id'])
-cellular_upload:
- exp: metrickit_cellular_upload.sum(['service_name', 'service_instance_id'])
-
-# UI quality — average across devices
-scroll_hitch_ratio:
- exp: metrickit_scroll_hitch_ratio.avg(['service_name', 'service_instance_id'])
+metricsRules:
+ # App responsiveness — percentile across devices (P50 = median)
+ - name: app_launch_time_percentile
+ exp: metrickit_app_launch_time_histogram.sum(['service_name', 'service_instance_id', 'le']).histogram().histogram_percentile([50,75,90,95,99])
+ - name: hang_time_percentile
+ exp: metrickit_hang_time_histogram.sum(['service_name', 'service_instance_id', 'le']).histogram().histogram_percentile([50,75,90,95,99])
+ - name: hang_time_sum
+ exp: metrickit_hang_time.sum(['service_name', 'service_instance_id'])
+
+ # Stability — sum across devices. Foreground/background are reported separately so
+ # watchdog kills and background-task crashes don't hide behind total crash counts.
+ # Normal graceful exits are not aggregated (no diagnostic signal).
+ - name: foreground_abnormal_exit_count
+ exp: metrickit_foreground_abnormal_exit_count.sum(['service_name', 'service_instance_id'])
+ - name: background_abnormal_exit_count
+ exp: metrickit_background_abnormal_exit_count.sum(['service_name', 'service_instance_id'])
+ - name: background_oom_kill_count
+ exp: metrickit_background_oom_kill_count.sum(['service_name', 'service_instance_id'])
+
+ # Resource usage
+ - name: peak_memory
+ exp: metrickit_peak_memory.max(['service_name', 'service_instance_id'])
+ - name: cpu_time
+ exp: metrickit_cpu_time.sum(['service_name', 'service_instance_id'])
+ - name: gpu_time
+ exp: metrickit_gpu_time.sum(['service_name', 'service_instance_id'])
+ - name: disk_write
+ exp: metrickit_disk_write.sum(['service_name', 'service_instance_id'])
+
+ # Network transfer — average per device
+ - name: wifi_download
+ exp: metrickit_wifi_download.avg(['service_name', 'service_instance_id'])
+ - name: wifi_upload
+ exp: metrickit_wifi_upload.avg(['service_name', 'service_instance_id'])
+ - name: cellular_download
+ exp: metrickit_cellular_download.avg(['service_name', 'service_instance_id'])
+ - name: cellular_upload
+ exp: metrickit_cellular_upload.avg(['service_name', 'service_instance_id'])
+
+ # UI quality
+ - name: scroll_hitch_ratio
+ exp: metrickit_scroll_hitch_ratio.avg(['service_name', 'service_instance_id'])
```
+The listener emits histogram-bucketed samples (with `le` labels) for app launch time and
+hang time, enabling `histogram_percentile` to compute P50/P75/P90/P95/P99 across the device fleet.
+
+**Bucket ceiling**: both histograms top out at a finite 30 s bucket rather than `+Inf`. MAL
+parses `le="Infinity"` to `(long) Double.POSITIVE_INFINITY = Long.MAX_VALUE` and surfaces it
+verbatim in percentile queries; on a dashboard that renders as ~9.2×10¹⁸, which is worse than
+a visibly alarming but human-readable cap. Values above 30 s are vanishingly rare for iOS app
+launch / hang observations (MetricKit itself hard-caps hangs near 30 s), so the finite sentinel
+preserves percentile accuracy without breaking the UI.
+
#### Aggregation Example
Given 3 devices reporting on the same day for service "MyApp":
@@ -556,12 +510,13 @@ Device C: appLaunchTime=900ms, peakMemory=140MB, abnormalExitCount=1, wifiDownlo
Resulting daily metrics:
-| Metric | Aggregation | Result |
-|---|---|---|
-| `ios_app_launch_time` | avg(850, 1200, 900) | **983 ms** |
-| `ios_peak_memory` | max(150, 200, 140) | **200 MB** |
-| `ios_abnormal_exit_count` | sum(2, 0, 1) | **3 crashes** |
-| `ios_wifi_download` | sum(50, 80, 30) | **160 MB** |
+| Metric | Aggregation | Result |
+|--------------------------------|--------------------------|----------------|
+| `ios_app_launch_time_percentile P50` | histogram_percentile | **900 ms** |
+| `ios_app_launch_time_percentile P90` | histogram_percentile | **1200 ms** |
+| `ios_peak_memory` | max(150, 200, 140) | **200 MB** |
+| `ios_foreground_abnormal_exit_count` | sum(2, 0, 1) | **3 crashes** |
+| `ios_wifi_download` | avg(50, 80, 30) | **53 MB** |
### 10. MetricKit Diagnostic Log Processing (LAL)
@@ -688,21 +643,19 @@ Create dashboards under `ui-initialized-templates/ios/`:
| Panel Group | Metrics | Source |
|------------------|----------------------------------------------------------------|------------------------------|
-| HTTP Performance | `service_cpm`, `service_resp_time`, `service_percentile` | OAL (from HTTP trace spans) |
-| HTTP Errors | `service_sla` (success rate) | OAL |
-| App Launch | `meter_ios_app_launch_time` | MetricKit analyzer |
-| Memory | `meter_ios_peak_memory` | MetricKit analyzer |
-| CPU | `meter_ios_cpu_time` | MetricKit analyzer |
-| Network Transfer | `meter_ios_wifi_download`, `meter_ios_cellular_download`, etc. | MetricKit analyzer |
-| Stability | `meter_ios_abnormal_exit_count`, `meter_ios_oom_kill_count` | MetricKit analyzer |
-| Responsiveness | `meter_ios_hang_time`, `meter_ios_scroll_hitch_ratio` | MetricKit analyzer |
+| HTTP Traffic | `service_cpm`, `service_resp_time`, `service_sla`, `service_percentile` | OAL (from IOSHTTPSpanListener) |
+| App Launch | `meter_ios_app_launch_time` | MetricKit MAL |
+| Stability | `meter_ios_foreground_abnormal_exit_count`, `meter_ios_background_oom_kill_count` | MetricKit MAL |
+| Memory | `meter_ios_peak_memory` | MetricKit MAL |
+| Network Transfer | `meter_ios_wifi_download`, `meter_ios_cellular_download`, etc. | MetricKit MAL |
+| Responsiveness | `meter_ios_hang_time` | MetricKit MAL |
**ios-instance.json** — Per-version dashboard (instance = app version):
-- Same HTTP performance metrics scoped to instance
+- HTTP traffic metrics scoped to instance (`service_instance_cpm`, `service_instance_resp_time`, `service_instance_sla`)
- MetricKit metrics per version
-**ios-endpoint.json** — Per-HTTP-endpoint dashboard:
-- `endpoint_cpm`, `endpoint_resp_time`, `endpoint_percentile`, `endpoint_sla` (from OAL)
+**ios-endpoint.json** — Per-domain dashboard (endpoint = `net.peer.name` domain):
+- `endpoint_cpm`, `endpoint_resp_time`, `endpoint_sla`, `endpoint_percentile` (from OAL)
#### UI Side
diff --git a/docs/menu.yml b/docs/menu.yml
index d7de08dbb1..d2477663b6 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -104,6 +104,10 @@ catalog:
path: "/en/setup/backend/backend-aws-api-gateway-monitoring"
- name: "Browser Monitoring"
path: "/en/setup/service-agent/browser-agent"
+ - name: "Mobile Monitoring"
+ catalog:
+ - name: "iOS"
+ path: "/en/setup/backend/backend-ios-monitoring"
- name: "Gateway Monitoring"
catalog:
- name: "Nginx Monitoring"
diff --git a/oap-server/analyzer/ios-analyzer/pom.xml b/oap-server/analyzer/ios-analyzer/pom.xml
index 93f954baed..6e0b002a1f 100644
--- a/oap-server/analyzer/ios-analyzer/pom.xml
+++ b/oap-server/analyzer/ios-analyzer/pom.xml
@@ -35,7 +35,7 @@
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
- <artifactId>meter-analyzer</artifactId>
+ <artifactId>otel-receiver-plugin</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
diff --git a/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSHTTPSpanListener.java b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSHTTPSpanListener.java
new file mode 100644
index 0000000000..7904b57be3
--- /dev/null
+++ b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSHTTPSpanListener.java
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.skywalking.oap.analyzer.ios.listener;
+
+import java.util.Collections;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.analysis.Layer;
+import org.apache.skywalking.oap.server.core.analysis.TimeBucket;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.source.Endpoint;
+import org.apache.skywalking.oap.server.core.source.RequestType;
+import org.apache.skywalking.oap.server.core.source.Service;
+import org.apache.skywalking.oap.server.core.source.ServiceInstance;
+import org.apache.skywalking.oap.server.core.source.ServiceMeta;
+import org.apache.skywalking.oap.server.core.source.SourceReceiver;
+import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
+import org.apache.skywalking.oap.server.core.trace.SpanListener;
+import org.apache.skywalking.oap.server.core.trace.SpanListenerResult;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+
+/**
+ * Extracts per-request HTTP traffic metrics from iOS URLSession spans and emits
+ * OAL sources ({@link Service}, {@link ServiceInstance}, {@link Endpoint}).
+ *
+ * <p>Detection: resource attribute {@code os.name} is {@code iOS} or {@code iPadOS},
+ * the instrumentation scope is {@code NSURLSession}, the span kind is
+ * {@code CLIENT}, and the span has {@code http.method} plus
+ * {@code net.peer.name} (domain name).
+ *
+ * <p>These keys match the current default output of the OpenTelemetry Swift
+ * {@code URLSessionInstrumentation}. Its default HTTP semantic convention is
+ * {@code .old}, which emits {@code net.peer.name} and {@code http.status_code}
+ * rather than the stable {@code server.address} and
+ * {@code http.response.status_code} keys.
+ *
+ * <p>Endpoint name uses the domain only ({@code net.peer.name}) to keep cardinality
+ * manageable on the client side.
+ *
+ * <p>The existing {@code core.oal} rules automatically produce traffic metrics
+ * ({@code service_cpm}, {@code service_resp_time}, {@code endpoint_cpm}, etc.)
+ * from these sources, all under {@link Layer#IOS}.
+ */
+@Slf4j
+public class IOSHTTPSpanListener implements SpanListener {
+ private SourceReceiver sourceReceiver;
+ private NamingControl namingControl;
+
+ @Override
+ public String[] requiredModules() {
+ return new String[] {CoreModule.NAME};
+ }
+
+ @Override
+ public void init(final ModuleManager moduleManager) {
+ sourceReceiver = moduleManager.find(CoreModule.NAME)
+ .provider()
+ .getService(SourceReceiver.class);
+ namingControl = moduleManager.find(CoreModule.NAME)
+ .provider()
+ .getService(NamingControl.class);
+ }
+
+ @Override
+ public SpanListenerResult onOTLPSpan(final OTLPSpanReader span,
+ final Map<String, String> resourceAttributes,
+ final String scopeName,
+ final String scopeVersion) {
+ final String osName = resourceAttributes.get("os.name");
+ if (!"iOS".equals(osName) && !"iPadOS".equals(osName)) {
+ return SpanListenerResult.CONTINUE;
+ }
+
+ // OpenTelemetry Swift URLSessionInstrumentation supports three HTTP
+ // semantic-convention modes: `.old` (legacy: net.peer.name / http.method /
+ // http.status_code), `.stable` (server.address / http.request.method /
+ // http.response.status_code), and `.httpDup` (both). We prefer the stable
+ // keys and fall back to legacy so all three modes work.
+ if (!"NSURLSession".equals(scopeName) || !"SPAN_KIND_CLIENT".equals(span.spanKind())) {
+ return SpanListenerResult.CONTINUE;
+ }
+
+ final String httpMethod = attr(span, "http.request.method", "http.method");
+ if (httpMethod == null || httpMethod.isEmpty()) {
+ return SpanListenerResult.CONTINUE;
+ }
+
+ final String domain = attr(span, "server.address", "net.peer.name");
+ if (domain == null || domain.isEmpty()) {
+ return SpanListenerResult.CONTINUE;
+ }
+
+ final String rawServiceName = resourceAttributes.getOrDefault("service.name", "");
+ if (rawServiceName.isEmpty()) {
+ return SpanListenerResult.CONTINUE;
+ }
+
+ final String serviceName = namingControl.formatServiceName(rawServiceName);
+ final String instanceName = resourceAttributes.getOrDefault("service.version", "");
+ final String endpointName = namingControl.formatEndpointName(serviceName, domain);
+ final long now = System.currentTimeMillis();
+ final long endTimeMs = span.endTimeNanos() > 0 ? span.endTimeNanos() / 1_000_000 : now;
+ final long startTimeMs = span.startTimeNanos() > 0 ? span.startTimeNanos() / 1_000_000 : endTimeMs;
+ final long timeBucket = TimeBucket.getMinuteTimeBucket(endTimeMs);
+ final int latency = (int) (endTimeMs - startTimeMs);
+
+ final String statusCodeStr = attr(span, "http.response.status_code", "http.status_code");
+ int httpStatusCode = 0;
+ if (statusCodeStr != null && !statusCodeStr.isEmpty()) {
+ try {
+ httpStatusCode = Integer.parseInt(statusCodeStr);
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ final boolean status = httpStatusCode == 0 || httpStatusCode < 400;
+
+ // ServiceMeta — register the service with IOS layer
+ final ServiceMeta serviceMeta = new ServiceMeta();
+ serviceMeta.setName(serviceName);
+ serviceMeta.setLayer(Layer.IOS);
+ serviceMeta.setTimeBucket(timeBucket);
+ sourceReceiver.receive(serviceMeta);
+
+ // Service source — feeds OAL service_cpm, service_resp_time, service_sla, etc.
+ final Service service = new Service();
+ service.setName(serviceName);
+ service.setLayer(Layer.IOS);
+ service.setServiceInstanceName(instanceName);
+ service.setEndpointName(endpointName);
+ service.setLatency(latency);
+ service.setStatus(status);
+ service.setHttpResponseStatusCode(httpStatusCode);
+ service.setType(RequestType.HTTP);
+ service.setTags(Collections.emptyList());
+ service.setOriginalTags(Collections.emptyMap());
+ service.setTimeBucket(timeBucket);
+ sourceReceiver.receive(service);
+
+ // ServiceInstance source — feeds OAL service_instance_cpm, etc.
+ if (!instanceName.isEmpty()) {
+ final ServiceInstance serviceInstance = new ServiceInstance();
+ serviceInstance.setName(instanceName);
+ serviceInstance.setServiceName(serviceName);
+ serviceInstance.setServiceLayer(Layer.IOS);
+ serviceInstance.setEndpointName(endpointName);
+ serviceInstance.setLatency(latency);
+ serviceInstance.setStatus(status);
+ serviceInstance.setHttpResponseStatusCode(httpStatusCode);
+ serviceInstance.setType(RequestType.HTTP);
+ serviceInstance.setTags(Collections.emptyList());
+ serviceInstance.setOriginalTags(Collections.emptyMap());
+ serviceInstance.setTimeBucket(timeBucket);
+ sourceReceiver.receive(serviceInstance);
+ }
+
+ // Endpoint source — feeds OAL endpoint_cpm, endpoint_resp_time, etc.
+ // Endpoint name is the domain (net.peer.name) only
+ final Endpoint endpoint = new Endpoint();
+ endpoint.setName(endpointName);
+ endpoint.setServiceName(serviceName);
+ endpoint.setServiceLayer(Layer.IOS);
+ endpoint.setServiceInstanceName(instanceName);
+ endpoint.setLatency(latency);
+ endpoint.setStatus(status);
+ endpoint.setHttpResponseStatusCode(httpStatusCode);
+ endpoint.setType(RequestType.HTTP);
+ endpoint.setTags(Collections.emptyList());
+ endpoint.setOriginalTags(Collections.emptyMap());
+ endpoint.setTimeBucket(timeBucket);
+ sourceReceiver.receive(endpoint);
+
+ return SpanListenerResult.CONTINUE;
+ }
+
+ /**
+ * Prefer the OTel stable HTTP semconv attribute, fall back to the legacy key.
+ * Supports all three Swift URLSessionInstrumentation modes (.old, .stable, .httpDup).
+ */
+ private static String attr(final OTLPSpanReader span, final String stableKey, final String legacyKey) {
+ final String v = span.getAttribute(stableKey);
+ if (v != null && !v.isEmpty()) {
+ return v;
+ }
+ return span.getAttribute(legacyKey);
+ }
+}
diff --git a/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSLayerSpanListener.java b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSLayerSpanListener.java
deleted file mode 100644
index 0548991629..0000000000
--- a/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSLayerSpanListener.java
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.skywalking.oap.analyzer.ios.listener;
-
-import java.util.Map;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.skywalking.oap.server.core.CoreModule;
-import org.apache.skywalking.oap.server.core.analysis.Layer;
-import org.apache.skywalking.oap.server.core.analysis.TimeBucket;
-import org.apache.skywalking.oap.server.core.config.NamingControl;
-import org.apache.skywalking.oap.server.core.source.ServiceMeta;
-import org.apache.skywalking.oap.server.core.source.SourceReceiver;
-import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
-import org.apache.skywalking.oap.server.core.trace.SpanListener;
-import org.apache.skywalking.oap.server.core.trace.SpanListenerResult;
-import org.apache.skywalking.oap.server.library.module.ModuleManager;
-
-/**
- * Detects iOS/iPadOS spans from the {@code os.name} resource attribute and registers
- * the service with {@link Layer#IOS}.
- *
- * <p>Implements phase 1 ({@link #onOTLPSpan}) only — HTTP trace spans are persisted
- * normally via the Zipkin pipeline; this listener just ensures the service layer is set.
- */
-@Slf4j
-public class IOSLayerSpanListener implements SpanListener {
- private SourceReceiver sourceReceiver;
- private NamingControl namingControl;
-
- @Override
- public String[] requiredModules() {
- return new String[] {CoreModule.NAME};
- }
-
- @Override
- public void init(final ModuleManager moduleManager) {
- sourceReceiver = moduleManager.find(CoreModule.NAME)
- .provider()
- .getService(SourceReceiver.class);
- namingControl = moduleManager.find(CoreModule.NAME)
- .provider()
- .getService(NamingControl.class);
- }
-
- @Override
- public SpanListenerResult onOTLPSpan(final OTLPSpanReader span,
- final Map<String, String> resourceAttributes,
- final String scopeName,
- final String scopeVersion) {
- final String osName = resourceAttributes.get("os.name");
- if (!"iOS".equals(osName) && !"iPadOS".equals(osName)) {
- return SpanListenerResult.CONTINUE;
- }
-
- final String serviceName = resourceAttributes.get("service.name");
- if (serviceName == null || serviceName.isEmpty()) {
- return SpanListenerResult.CONTINUE;
- }
-
- // Register the service with IOS layer
- final ServiceMeta serviceMeta = new ServiceMeta();
- serviceMeta.setName(namingControl.formatServiceName(serviceName));
- serviceMeta.setLayer(Layer.IOS);
- serviceMeta.setTimeBucket(TimeBucket.getMinuteTimeBucket(
- span.endTimeNanos() / 1_000_000));
- sourceReceiver.receive(serviceMeta);
-
- return SpanListenerResult.CONTINUE;
- }
-}
diff --git a/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSMetricKitSpanListener.java b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSMetricKitSpanListener.java
index 87f86c6b52..f91cc72e63 100644
--- a/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSMetricKitSpanListener.java
+++ b/oap-server/analyzer/ios-analyzer/src/main/java/org/apache/skywalking/oap/analyzer/ios/listener/IOSMetricKitSpanListener.java
@@ -19,57 +19,67 @@ package org.apache.skywalking.oap.analyzer.ios.listener;
import com.google.common.collect.ImmutableMap;
import java.util.HashMap;
-import java.util.List;
import java.util.Map;
+import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
-import org.apache.skywalking.oap.meter.analyzer.v2.MetricConvert;
import org.apache.skywalking.oap.meter.analyzer.v2.dsl.Sample;
import org.apache.skywalking.oap.meter.analyzer.v2.dsl.SampleFamily;
import org.apache.skywalking.oap.meter.analyzer.v2.dsl.SampleFamilyBuilder;
-import org.apache.skywalking.oap.meter.analyzer.v2.prometheus.rule.Rule;
-import org.apache.skywalking.oap.meter.analyzer.v2.prometheus.rule.Rules;
-import org.apache.skywalking.oap.server.core.CoreModule;
-import org.apache.skywalking.oap.server.core.analysis.meter.MeterSystem;
import org.apache.skywalking.oap.server.core.trace.OTLPSpanReader;
import org.apache.skywalking.oap.server.core.trace.SpanListener;
import org.apache.skywalking.oap.server.core.trace.SpanListenerResult;
import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.receiver.otel.OtelMetricReceiverModule;
+import org.apache.skywalking.oap.server.receiver.otel.otlp.OpenTelemetryMetricRequestProcessor;
/**
* Extracts Apple MetricKit daily statistics from OTLP spans and converts them
* to MAL metrics. MetricKit spans are NOT stored as traces (24-hour duration).
*
* <p>Detection: {@code scopeName == "MetricKit"} AND {@code spanName == "MXMetricPayload"}.
+ * These values come from the current OpenTelemetry Swift MetricKit
+ * instrumentation, which builds the tracer/logger with scope name
+ * {@code "MetricKit"} and reports the main aggregated metrics span as
+ * {@code "MXMetricPayload"}.
*
* <p>The listener converts span attributes into labeled {@link SampleFamily} samples
- * and feeds them into the MAL pipeline via {@link MetricConvert}.
+ * and pushes them into the shared MAL pipeline via
+ * {@link OpenTelemetryMetricRequestProcessor#toMeter}.
+ * MAL rules are loaded by the otel-receiver module via {@code enabledOtelMetricsRules}.
*/
@Slf4j
public class IOSMetricKitSpanListener implements SpanListener {
- private List<MetricConvert> converters;
+ /**
+ * Histogram bucket boundaries for app launch time (ms).
+ * Covers common ranges from fast launch to slow cold start. The last bucket (30 s)
+ * acts as a finite overflow sentinel: observations above it are catastrophic and
+ * should remain visible on the chart rather than landing in a {@code +Inf} bucket
+ * (which would surface as {@code Long.MAX_VALUE} in percentile queries).
+ */
+ private static final double[] APP_LAUNCH_BUCKETS =
+ {400, 600, 800, 1000, 1500, 2000, 3000, 5000, 10000, 30000};
+
+ /**
+ * Histogram bucket boundaries for hang time (ms).
+ * Covers common ranges from minor UI jank to severe hangs. MetricKit hard-caps
+ * hang observation near 30 s; that value is used as the finite overflow sentinel
+ * (see {@link #APP_LAUNCH_BUCKETS} for rationale).
+ */
+ private static final double[] HANG_TIME_BUCKETS =
+ {250, 400, 600, 1000, 1500, 2000, 3000, 5000, 10000, 30000};
+
+ private OpenTelemetryMetricRequestProcessor metricProcessor;
@Override
public String[] requiredModules() {
- return new String[] {CoreModule.NAME};
+ return new String[] {OtelMetricReceiverModule.NAME};
}
@Override
public void init(final ModuleManager moduleManager) {
- final MeterSystem meterSystem = moduleManager.find(CoreModule.NAME)
- .provider()
- .getService(MeterSystem.class);
- try {
- final List<Rule> rules = Rules.loadRules(
- "otel-rules", java.util.Collections.singletonList("ios"));
- converters = rules.stream()
- .map(r -> new MetricConvert(r, meterSystem))
- .collect(java.util.stream.Collectors.toList());
- } catch (java.io.IOException e) {
- log.warn("Failed to load iOS MAL rules from otel-rules/ios/, "
- + "MetricKit metrics will not be processed", e);
- converters = java.util.Collections.emptyList();
- }
- log.info("IOSMetricKitSpanListener initialized with {} MAL converters", converters.size());
+ metricProcessor = moduleManager.find(OtelMetricReceiverModule.NAME)
+ .provider()
+ .getService(OpenTelemetryMetricRequestProcessor.class);
}
@Override
@@ -77,20 +87,23 @@ public class IOSMetricKitSpanListener implements SpanListener {
final Map<String, String> resourceAttributes,
final String scopeName,
final String scopeVersion) {
- if (!"MetricKit".equals(scopeName) || !"MXMetricPayload".equals(span.spanName())) {
+ final String osName = resourceAttributes.get("os.name");
+ if (!"iOS".equals(osName) && !"iPadOS".equals(osName)) {
return SpanListenerResult.CONTINUE;
}
-
- if (converters.isEmpty()) {
- log.debug("No MAL converters for iOS MetricKit, skipping");
- return SpanListenerResult.builder().shouldPersist(false).build();
+ // OpenTelemetry Swift MetricKit instrumentation emits the aggregated
+ // daily metrics span under scope "MetricKit" with span name
+ // "MXMetricPayload".
+ if (!"MetricKit".equals(scopeName) || !"MXMetricPayload".equals(span.spanName())) {
+ return SpanListenerResult.CONTINUE;
}
final String serviceName = resourceAttributes.getOrDefault("service.name", "");
final String serviceVersion = resourceAttributes.getOrDefault("service.version", "");
final String deviceModel = getDeviceModel(span, resourceAttributes);
final String osVersion = getOsVersion(span, resourceAttributes);
- final long timestamp = span.endTimeNanos() / 1_000_000;
+ final long timestamp = span.endTimeNanos() > 0
+ ? span.endTimeNanos() / 1_000_000 : System.currentTimeMillis();
final ImmutableMap<String, String> labels = ImmutableMap.of(
"service_name", serviceName,
@@ -101,8 +114,9 @@ public class IOSMetricKitSpanListener implements SpanListener {
// Build sample families from span attributes
final Map<String, SampleFamily> sampleFamilies = new HashMap<>();
- addSample(sampleFamilies, "metrickit_app_launch_time", span, "metrickit.app_launch.time_to_first_draw_average", labels, timestamp, 1000.0);
+ addHistogramSamples(sampleFamilies, "metrickit_app_launch_time_histogram", span, "metrickit.app_launch.time_to_first_draw_average", labels, timestamp, 1000.0, APP_LAUNCH_BUCKETS);
addSample(sampleFamilies, "metrickit_hang_time", span, "metrickit.app_responsiveness.hang_time_average", labels, timestamp, 1000.0);
+ addHistogramSamples(sampleFamilies, "metrickit_hang_time_histogram", span, "metrickit.app_responsiveness.hang_time_average", labels, timestamp, 1000.0, HANG_TIME_BUCKETS);
addSample(sampleFamilies, "metrickit_cpu_time", span, "metrickit.cpu.cpu_time", labels, timestamp, 1.0);
addSample(sampleFamilies, "metrickit_gpu_time", span, "metrickit.gpu.time", labels, timestamp, 1.0);
addSample(sampleFamilies, "metrickit_peak_memory", span, "metrickit.memory.peak_memory_usage", labels, timestamp, 1.0);
@@ -111,14 +125,13 @@ public class IOSMetricKitSpanListener implements SpanListener {
addSample(sampleFamilies, "metrickit_wifi_upload", span, "metrickit.network_transfer.wifi_upload", labels, timestamp, 1.0);
addSample(sampleFamilies, "metrickit_cellular_download", span, "metrickit.network_transfer.cellular_download", labels, timestamp, 1.0);
addSample(sampleFamilies, "metrickit_cellular_upload", span, "metrickit.network_transfer.cellular_upload", labels, timestamp, 1.0);
- addSample(sampleFamilies, "metrickit_abnormal_exit_count", span, "metrickit.app_exit.foreground.abnormal_exit_count", labels, timestamp, 1.0);
- addSample(sampleFamilies, "metrickit_normal_exit_count", span, "metrickit.app_exit.foreground.normal_app_exit_count", labels, timestamp, 1.0);
- addSample(sampleFamilies, "metrickit_oom_kill_count", span, "metrickit.app_exit.background.memory_pressure_exit_count", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_foreground_abnormal_exit_count", span, "metrickit.app_exit.foreground.abnormal_exit_count", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_background_abnormal_exit_count", span, "metrickit.app_exit.background.abnormal_exit_count", labels, timestamp, 1.0);
+ addSample(sampleFamilies, "metrickit_background_oom_kill_count", span, "metrickit.app_exit.background.memory_pressure_exit_count", labels, timestamp, 1.0);
addSample(sampleFamilies, "metrickit_scroll_hitch_ratio", span, "metrickit.animation.scroll_hitch_time_ratio", labels, timestamp, 1.0);
if (!sampleFamilies.isEmpty()) {
- final ImmutableMap<String, SampleFamily> immutableSamples = ImmutableMap.copyOf(sampleFamilies);
- converters.forEach(convert -> convert.toMeter(immutableSamples));
+ metricProcessor.toMeter(ImmutableMap.copyOf(sampleFamilies));
}
// Do NOT persist as trace — 24-hour span is not meaningful in trace view
@@ -150,9 +163,61 @@ public class IOSMetricKitSpanListener implements SpanListener {
}
}
+ private void addHistogramSamples(final Map<String, SampleFamily> families,
+ final String metricName,
+ final OTLPSpanReader span,
+ final String attrKey,
+ final ImmutableMap<String, String> labels,
+ final long timestamp,
+ final double multiplier,
+ final double[] buckets) {
+ final String rawValue = span.getAttribute(attrKey);
+ if (rawValue == null || rawValue.isEmpty()) {
+ return;
+ }
+ try {
+ final double value = Double.parseDouble(rawValue) * multiplier;
+ // Per-bucket counts (non-cumulative): place the observation into the smallest
+ // bucket whose upper bound is >= value, matching the OTLP histogram convention
+ // that MAL's histogram_percentile expects. Values exceeding the largest bucket
+ // fall into the final (30 s) overflow sentinel.
+ int targetBucket = buckets.length - 1;
+ for (int i = 0; i < buckets.length; i++) {
+ if (value <= buckets[i]) {
+ targetBucket = i;
+ break;
+ }
+ }
+ final Sample[] samples = new Sample[buckets.length];
+ for (int i = 0; i < buckets.length; i++) {
+ final ImmutableMap<String, String> bucketLabels = ImmutableMap.<String, String>builder()
+ .putAll(labels)
+ .put("le", String.valueOf((long) buckets[i]))
+ .build();
+ samples[i] = Sample.builder()
+ .name(metricName)
+ .labels(bucketLabels)
+ .value(i == targetBucket ? 1 : 0)
+ .timestamp(timestamp)
+ .build();
+ }
+ // Bucket `le` labels are already in milliseconds; disable MAL's default
+ // SECONDS→MS rescale (which would otherwise multiply them by 1000).
+ families.put(
+ metricName,
+ SampleFamilyBuilder.newBuilder(samples)
+ .defaultHistogramBucketUnit(TimeUnit.MILLISECONDS)
+ .build());
+ } catch (NumberFormatException e) {
+ log.debug("Failed to parse MetricKit attribute {}: {}", attrKey, rawValue);
+ }
+ }
+
private static String getDeviceModel(final OTLPSpanReader span,
final Map<String, String> resourceAttributes) {
- // Prefer span-level metadata (from MetricKit payload) over resource attribute
+ // OpenTelemetry Swift MetricKit instrumentation puts device metadata on
+ // the payload span as `metrickit.metadata.*` attributes. Prefer those
+ // over the generic resource attributes when available.
final String fromSpan = span.getAttribute("metrickit.metadata.device_type");
if (fromSpan != null && !fromSpan.isEmpty()) {
return fromSpan;
@@ -162,6 +227,8 @@ public class IOSMetricKitSpanListener implements SpanListener {
private static String getOsVersion(final OTLPSpanReader span,
final Map<String, String> resourceAttributes) {
+ // Same as device type above: prefer the MetricKit payload metadata
+ // attribute over the generic resource-level OS version.
final String fromSpan = span.getAttribute("metrickit.metadata.os_version");
if (fromSpan != null && !fromSpan.isEmpty()) {
return fromSpan;
diff --git a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener b/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
index b18e6ee2d9..590bd58334 100644
--- a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
+++ b/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
@@ -14,5 +14,5 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
-org.apache.skywalking.oap.analyzer.ios.listener.IOSLayerSpanListener
+org.apache.skywalking.oap.analyzer.ios.listener.IOSHTTPSpanListener
org.apache.skywalking.oap.analyzer.ios.listener.IOSMetricKitSpanListener
diff --git a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALBlockCodegen.java b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALBlockCodegen.java
index 6862c15126..0912f8decb 100644
--- a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALBlockCodegen.java
+++ b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALBlockCodegen.java
@@ -273,6 +273,15 @@ final class LALBlockCodegen {
LALValueCodegen.generateCastedValueAccess(sb, field.getValue(), effectiveCast, genCtx);
}
sb.append(");\n");
+
+ // LAYER is also used by FilterSpec.doSink() (layer:auto check) and by downstream routing
+ // that reads from LogMetadata. Propagate the LAL-decided layer to metadata so the output
+ // builder's initFromMetadata() and other metadata consumers see the same value.
+ if (field.getFieldType() == LALScriptModel.FieldType.LAYER) {
+ sb.append(" h.ctx().metadata().setLayer(");
+ LALValueCodegen.generateCastedValueAccess(sb, field.getValue(), "String", genCtx);
+ sb.append(");\n");
+ }
}
static void generateIfBlockInExtractor(
diff --git a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/provider/log/listener/RecordSinkListener.java b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/provider/log/listener/RecordSinkListener.java
index 4d6e465e2b..e65a9031ce 100644
--- a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/provider/log/listener/RecordSinkListener.java
+++ b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/provider/log/listener/RecordSinkListener.java
@@ -49,6 +49,14 @@ public class RecordSinkListener implements LogSinkListener {
this.moduleManager = moduleManager;
}
+ /**
+ * Dispatch the finalized output. {@link LALOutputBuilder#complete(SourceReceiver)}
+ * is polymorphic: the default {@code LogBuilder} emits a {@code Log} source; custom
+ * builders registered by receivers (e.g., {@code EnvoyAccessLogBuilder},
+ * {@code DatabaseSlowStatementBuilder}, {@code SampledTraceBuilder}) emit their own
+ * source types. {@code builder} is {@code null} only when {@link #parse} skipped —
+ * in which case there's nothing to persist.
+ */
@Override
public void build() {
if (builder == null) {
@@ -61,6 +69,18 @@ public class RecordSinkListener implements LogSinkListener {
builder.complete(sourceReceiver);
}
+ /**
+ * Pick up the output object produced by the LAL extractor and prepare it for dispatch.
+ *
+ * <p>The LAL codegen always creates an output at the start of {@code execute()}
+ * via {@code ctx.setOutput(new OutputType())}. {@code OutputType} is resolved per rule
+ * from (in order): the YAML rule's {@code outputType} field, an
+ * {@code LALSourceTypeProvider} SPI contribution for the layer, or the default
+ * {@code LogBuilder}. All valid output types implement {@link LALOutputBuilder}, so
+ * the {@code instanceof} check below is a defensive null-guard, not a selector.
+ * Calling {@code init()} populates standard fields from metadata (service, layer,
+ * timestamp, trace context) only if the extractor left them blank.
+ */
@Override
@SneakyThrows
public LogSinkListener parse(final LogMetadata metadata,
@@ -74,10 +94,6 @@ public class RecordSinkListener implements LogSinkListener {
return this;
}
- public LALOutputBuilder getBuilder() {
- return builder;
- }
-
public static class Factory implements LogSinkListenerFactory {
private final SourceReceiver sourceReceiver;
private final ModuleManager moduleManager;
diff --git a/oap-server/analyzer/log-analyzer/src/test/resources/scripts/lal/test-lal/oap-cases/ios-metrickit.data.yaml b/oap-server/analyzer/log-analyzer/src/test/resources/scripts/lal/test-lal/oap-cases/ios-metrickit.data.yaml
new file mode 100644
index 0000000000..cf0fa6cc34
--- /dev/null
+++ b/oap-server/analyzer/log-analyzer/src/test/resources/scripts/lal/test-lal/oap-cases/ios-metrickit.data.yaml
@@ -0,0 +1,86 @@
+# 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.
+
+# Test data for ios-metrickit LAL rule.
+
+ios-metrickit-diagnostics:
+ # Case 1: iOS crash diagnostic — should set layer IOS and extract tags
+ - service: MyiOSApp
+ body-type: none
+ body: ""
+ source-attributes:
+ os.name: "iOS"
+ os.version: "17.4.1"
+ device.model.identifier: "iPhone15,2"
+ service.version: "2.1.0"
+ tags:
+ name: "metrickit.diagnostic.crash"
+ exception.type: "EXC_BAD_ACCESS"
+ exception.message: "Could not access memory"
+ exception.stacktrace: "0 CoreFoundation 0x1a2b __exceptionPreprocess"
+ metrickit.diagnostic.crash.exception.signal.name: "SIGSEGV"
+ expect:
+ save: true
+ abort: false
+ layer: IOS
+ instance: "2.1.0"
+ tag.diagnosticType: "metrickit.diagnostic.crash"
+ tag.exception.type: "EXC_BAD_ACCESS"
+ tag.exception.message: "Could not access memory"
+ tag.signal.name: "SIGSEGV"
+ tag.device.model: "iPhone15,2"
+ tag.os.version: "17.4.1"
+
+ # Case 2: iPadOS hang diagnostic
+ - service: MyiOSApp
+ body-type: none
+ body: ""
+ source-attributes:
+ os.name: "iPadOS"
+ os.version: "16.6"
+ device.model.identifier: "iPad13,4"
+ service.version: "2.0.0"
+ tags:
+ name: "metrickit.diagnostic.hang"
+ exception.stacktrace: "0 MyApp 0x104a -[DataManager syncData]"
+ metrickit.diagnostic.hang.hang_duration: "3500"
+ expect:
+ save: true
+ abort: false
+ layer: IOS
+ instance: "2.0.0"
+ tag.diagnosticType: "metrickit.diagnostic.hang"
+ tag.hang.duration: "3500"
+ tag.device.model: "iPad13,4"
+
+ # Case 3: Non-iOS log — should abort (sourceAttribute os.name != iOS)
+ - service: AndroidApp
+ body-type: none
+ body: ""
+ source-attributes:
+ os.name: "Android"
+ tags:
+ name: "metrickit.diagnostic.crash"
+ expect:
+ abort: true
+
+ # Case 4: No source attributes (non-OTLP) — should abort
+ - service: MyApp
+ body-type: none
+ body: ""
+ tags:
+ name: "metrickit.diagnostic.crash"
+ expect:
+ abort: true
diff --git a/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml b/oap-server/analyzer/log-analyzer/src/test/resources/scripts/lal/test-lal/oap-cases/ios-metrickit.yaml
similarity index 92%
copy from oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml
copy to oap-server/analyzer/log-analyzer/src/test/resources/scripts/lal/test-lal/oap-cases/ios-metrickit.yaml
index f0ccc976ca..3f29f6bda9 100644
--- a/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml
+++ b/oap-server/analyzer/log-analyzer/src/test/resources/scripts/lal/test-lal/oap-cases/ios-metrickit.yaml
@@ -17,8 +17,8 @@
# Processes crash, hang, CPU exception, disk write exception, and app launch
# diagnostics from the OTel Swift SDK's MetricKit instrumentation.
#
-# These arrive as OTLP log records with InstrumentationScope "MetricKit"
-# and the diagnostic type identified by the "name" attribute.
+# Uses layer:auto mode — the script determines the IOS layer from
+# sourceAttribute("os.name") and sets instance from sourceAttribute("service.version").
rules:
- name: ios-metrickit-diagnostics
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/management/ui/template/UITemplateInitializer.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/management/ui/template/UITemplateInitializer.java
index 4651101e84..e5e24daa82 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/management/ui/template/UITemplateInitializer.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/management/ui/template/UITemplateInitializer.java
@@ -83,6 +83,7 @@ public class UITemplateInitializer {
Layer.BANYANDB.name(),
Layer.VIRTUAL_GENAI.name(),
Layer.ENVOY_AI_GATEWAY.name(),
+ Layer.IOS.name(),
"custom"
};
private final UITemplateManagementService uiTemplateManagementService;
diff --git a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryMetricRequestProcessor.java b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryMetricRequestProcessor.java
index 4f8730c55c..12e59fd49b 100644
--- a/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryMetricRequestProcessor.java
+++ b/oap-server/server-receiver-plugin/otel-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/otel/otlp/OpenTelemetryMetricRequestProcessor.java
@@ -149,6 +149,18 @@ public class OpenTelemetryMetricRequestProcessor implements Service {
}
}
+ /**
+ * Push pre-built sample families into the MAL pipeline.
+ * Used by SpanListeners (e.g., IOSMetricKitSpanListener) that extract
+ * metrics from OTLP spans and need to feed them through the same
+ * MAL converters configured via enabledOtelMetricsRules.
+ */
+ public void toMeter(final ImmutableMap<String, SampleFamily> sampleFamilies) {
+ if (converters != null) {
+ converters.forEach(convert -> convert.toMeter(sampleFamilies));
+ }
+ }
+
public void start() throws ModuleStartException {
final List<String> enabledRules =
Splitter.on(",")
diff --git a/oap-server/server-starter/pom.xml b/oap-server/server-starter/pom.xml
index cc510317a5..5b74ae9f52 100644
--- a/oap-server/server-starter/pom.xml
+++ b/oap-server/server-starter/pom.xml
@@ -53,6 +53,12 @@
<artifactId>hierarchy</artifactId>
<version>${project.version}</version>
</dependency>
+ <!-- iOS SpanListener (SPI-loaded by server-core) -->
+ <dependency>
+ <groupId>org.apache.skywalking</groupId>
+ <artifactId>ios-analyzer</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<!-- cluster module -->
<dependency>
diff --git a/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml b/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml
index f0ccc976ca..3f29f6bda9 100644
--- a/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml
+++ b/oap-server/server-starter/src/main/resources/lal/ios-metrickit.yaml
@@ -17,8 +17,8 @@
# Processes crash, hang, CPU exception, disk write exception, and app launch
# diagnostics from the OTel Swift SDK's MetricKit instrumentation.
#
-# These arrive as OTLP log records with InstrumentationScope "MetricKit"
-# and the diagnostic type identified by the "name" attribute.
+# Uses layer:auto mode — the script determines the IOS layer from
+# sourceAttribute("os.name") and sets instance from sourceAttribute("service.version").
rules:
- name: ios-metrickit-diagnostics
diff --git a/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit-instance.yaml b/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit-instance.yaml
new file mode 100644
index 0000000000..7f542cc20e
--- /dev/null
+++ b/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit-instance.yaml
@@ -0,0 +1,55 @@
+# 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.
+
+# iOS MetricKit instance-level (per app version) metrics.
+# Same metrics as ios-metrickit.yaml but scoped to service instance.
+
+expSuffix: instance(['service_name'], ['service_instance_id'], Layer.IOS)
+metricPrefix: meter_ios_instance
+
+metricsRules:
+ # App responsiveness
+ - name: app_launch_time_percentile
+ exp: metrickit_app_launch_time_histogram.sum(['service_name', 'service_instance_id', 'le']).histogram().histogram_percentile([50,75,90,95,99])
+ - name: hang_time_sum
+ exp: metrickit_hang_time.sum(['service_name', 'service_instance_id'])
+ - name: hang_time_percentile
+ exp: metrickit_hang_time_histogram.sum(['service_name', 'service_instance_id', 'le']).histogram().histogram_percentile([50,75,90,95,99])
+
+ # Stability — sum across devices for this version
+ - name: foreground_abnormal_exit_count
+ exp: metrickit_foreground_abnormal_exit_count.sum(['service_name', 'service_instance_id'])
+ - name: background_abnormal_exit_count
+ exp: metrickit_background_abnormal_exit_count.sum(['service_name', 'service_instance_id'])
+ - name: background_oom_kill_count
+ exp: metrickit_background_oom_kill_count.sum(['service_name', 'service_instance_id'])
+
+ # Resource usage
+ - name: peak_memory
+ exp: metrickit_peak_memory.max(['service_name', 'service_instance_id'])
+
+ # Network transfer — average per device for this version
+ - name: wifi_download
+ exp: metrickit_wifi_download.avg(['service_name', 'service_instance_id'])
+ - name: wifi_upload
+ exp: metrickit_wifi_upload.avg(['service_name', 'service_instance_id'])
+ - name: cellular_download
+ exp: metrickit_cellular_download.avg(['service_name', 'service_instance_id'])
+ - name: cellular_upload
+ exp: metrickit_cellular_upload.avg(['service_name', 'service_instance_id'])
+
+ # UI quality
+ - name: scroll_hitch_ratio
+ exp: metrickit_scroll_hitch_ratio.avg(['service_name', 'service_instance_id'])
diff --git a/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit.yaml b/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit.yaml
index a9d48d4539..3e78c909c5 100644
--- a/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit.yaml
+++ b/oap-server/server-starter/src/main/resources/otel-rules/ios/ios-metrickit.yaml
@@ -13,52 +13,49 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-# iOS MetricKit daily metrics from Apple's MetricKit framework.
-# Data arrives as OTLP spans (not metrics) via IOSMetricKitSpanListener,
-# which converts span attributes to labeled SampleFamily samples.
+# iOS MetricKit service-level metrics from Apple's MetricKit framework.
+# Aggregated across all app versions (instances) and devices.
-expSuffix: service(['service_name'], Layer.IOS).instance(['service_name', 'service_instance_id'])
+expSuffix: service(['service_name'], Layer.IOS)
metricPrefix: meter_ios
metricsRules:
- # App responsiveness — average across devices
- - name: app_launch_time
- exp: metrickit_app_launch_time.avg(['service_name', 'service_instance_id'])
- - name: app_launch_time_by_device
- exp: metrickit_app_launch_time.avg(['service_name', 'service_instance_id', 'device_model'])
- - name: hang_time
- exp: metrickit_hang_time.avg(['service_name', 'service_instance_id'])
+ # App responsiveness
+ - name: app_launch_time_percentile
+ exp: metrickit_app_launch_time_histogram.sum(['service_name', 'le']).histogram().histogram_percentile([50,75,90,95,99])
+ - name: hang_time_sum
+ exp: metrickit_hang_time.sum(['service_name'])
+ - name: hang_time_percentile
+ exp: metrickit_hang_time_histogram.sum(['service_name', 'le']).histogram().histogram_percentile([50,75,90,95,99])
- # Stability — sum across devices
- - name: abnormal_exit_count
- exp: metrickit_abnormal_exit_count.sum(['service_name', 'service_instance_id'])
- - name: abnormal_exit_count_by_os
- exp: metrickit_abnormal_exit_count.sum(['service_name', 'service_instance_id', 'os_version'])
- - name: normal_exit_count
- exp: metrickit_normal_exit_count.sum(['service_name', 'service_instance_id'])
- - name: oom_kill_count
- exp: metrickit_oom_kill_count.sum(['service_name', 'service_instance_id'])
+ # Stability — sum across all devices and versions
+ - name: foreground_abnormal_exit_count
+ exp: metrickit_foreground_abnormal_exit_count.sum(['service_name'])
+ - name: background_abnormal_exit_count
+ exp: metrickit_background_abnormal_exit_count.sum(['service_name'])
+ - name: background_oom_kill_count
+ exp: metrickit_background_oom_kill_count.sum(['service_name'])
# Resource usage
- name: peak_memory
- exp: metrickit_peak_memory.max(['service_name', 'service_instance_id'])
+ exp: metrickit_peak_memory.max(['service_name'])
- name: cpu_time
- exp: metrickit_cpu_time.sum(['service_name', 'service_instance_id'])
+ exp: metrickit_cpu_time.sum(['service_name'])
- name: gpu_time
- exp: metrickit_gpu_time.sum(['service_name', 'service_instance_id'])
+ exp: metrickit_gpu_time.sum(['service_name'])
- name: disk_write
- exp: metrickit_disk_write.sum(['service_name', 'service_instance_id'])
+ exp: metrickit_disk_write.sum(['service_name'])
- # Network transfer — sum across devices
+ # Network transfer — average per device
- name: wifi_download
- exp: metrickit_wifi_download.sum(['service_name', 'service_instance_id'])
+ exp: metrickit_wifi_download.avg(['service_name'])
- name: wifi_upload
- exp: metrickit_wifi_upload.sum(['service_name', 'service_instance_id'])
+ exp: metrickit_wifi_upload.avg(['service_name'])
- name: cellular_download
- exp: metrickit_cellular_download.sum(['service_name', 'service_instance_id'])
+ exp: metrickit_cellular_download.avg(['service_name'])
- name: cellular_upload
- exp: metrickit_cellular_upload.sum(['service_name', 'service_instance_id'])
+ exp: metrickit_cellular_upload.avg(['service_name'])
- # UI quality — average across devices
+ # UI quality
- name: scroll_hitch_ratio
- exp: metrickit_scroll_hitch_ratio.avg(['service_name', 'service_instance_id'])
+ exp: metrickit_scroll_hitch_ratio.avg(['service_name'])
diff --git a/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-endpoint.json b/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-endpoint.json
new file mode 100644
index 0000000000..9c5abea23a
--- /dev/null
+++ b/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-endpoint.json
@@ -0,0 +1,150 @@
+[
+ {
+ "id": "iOS-Endpoint",
+ "configuration": {
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 24,
+ "h": 34,
+ "i": "12",
+ "type": "Tab",
+ "children": [
+ {
+ "name": "Overview",
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 8,
+ "h": 15,
+ "i": "0",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound Load (calls / min)",
+ "tips": "Client-side outbound HTTP requests to this domain"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "endpoint_cpm"
+ ],
+ "associate": [
+ {
+ "widgetId": "12-0-1"
+ },
+ {
+ "widgetId": "12-0-2"
+ }
+ ]
+ },
+ {
+ "x": 8,
+ "y": 0,
+ "w": 8,
+ "h": 15,
+ "i": "1",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound Avg Response Time (ms)",
+ "tips": "Client-observed latency of requests to this domain"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "endpoint_resp_time"
+ ],
+ "associate": [
+ {
+ "widgetId": "12-0-0"
+ },
+ {
+ "widgetId": "12-0-2"
+ }
+ ]
+ },
+ {
+ "x": 16,
+ "y": 0,
+ "w": 8,
+ "h": 15,
+ "i": "2",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound Success Rate (%)",
+ "tips": "Success rate of requests to this domain"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "endpoint_sla/100"
+ ],
+ "associate": [
+ {
+ "widgetId": "12-0-0"
+ },
+ {
+ "widgetId": "12-0-1"
+ }
+ ]
+ },
+ {
+ "x": 0,
+ "y": 15,
+ "w": 24,
+ "h": 15,
+ "i": "3",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound Response Time Percentile (ms)",
+ "tips": "Client-observed latency distribution of requests to this domain"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "relabels(endpoint_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='P50,P75,P90,P95,P99')"
+ ],
+ "metricConfig": [
+ {
+ "label": "P50, P75, P90, P95, P99"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "layer": "IOS",
+ "entity": "Endpoint",
+ "name": "iOS-Endpoint",
+ "id": "iOS-Endpoint",
+ "isRoot": false
+ }
+ }
+]
diff --git a/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-instance.json b/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-instance.json
new file mode 100644
index 0000000000..3807e51b71
--- /dev/null
+++ b/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-instance.json
@@ -0,0 +1,348 @@
+[
+ {
+ "id": "iOS-Instance",
+ "configuration": {
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 24,
+ "h": 64,
+ "i": "12",
+ "type": "Tab",
+ "children": [
+ {
+ "name": "Overview",
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 12,
+ "h": 15,
+ "i": "0",
+ "type": "Widget",
+ "widget": {
+ "title": "App Launch Time Percentile (ms)",
+ "tips": "Distribution of app launch time for this app version"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "relabels(meter_ios_instance_app_launch_time_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='P50,P75,P90,P95,P99')"
+ ],
+ "metricConfig": [
+ {
+ "label": "P50, P75, P90, P95, P99"
+ }
+ ]
+ },
+ {
+ "x": 12,
+ "y": 0,
+ "w": 12,
+ "h": 15,
+ "i": "1",
+ "type": "Widget",
+ "widget": {
+ "title": "Hang Time Percentile (ms)",
+ "tips": "Distribution of UI hang duration for this app version"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "relabels(meter_ios_instance_hang_time_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='P50,P75,P90,P95,P99')"
+ ],
+ "metricConfig": [
+ {
+ "label": "P50, P75, P90, P95, P99"
+ }
+ ]
+ },
+ {
+ "x": 0,
+ "y": 15,
+ "w": 8,
+ "h": 15,
+ "i": "2",
+ "type": "Widget",
+ "widget": {
+ "title": "Hang Time Sum (ms)",
+ "tips": "Total UI hang time for this app version"
+ },
+ "graph": {
+ "type": "Bar",
+ "showBackground": true
+ },
+ "metricConfig": [
+ {
+ "unit": "ms"
+ }
+ ],
+ "expressions": [
+ "meter_ios_instance_hang_time_sum"
+ ]
+ },
+ {
+ "x": 8,
+ "y": 15,
+ "w": 8,
+ "h": 15,
+ "i": "3",
+ "type": "Widget",
+ "widget": {
+ "title": "Abnormal Exit Count (Crashes)",
+ "tips": "Abnormal exits (foreground + background) for this app version"
+ },
+ "graph": {
+ "type": "Bar",
+ "showBackground": true
+ },
+ "expressions": [
+ "meter_ios_instance_foreground_abnormal_exit_count",
+ "meter_ios_instance_background_abnormal_exit_count"
+ ],
+ "metricConfig": [
+ {
+ "label": "Foreground"
+ },
+ {
+ "label": "Background"
+ }
+ ]
+ },
+ {
+ "x": 16,
+ "y": 15,
+ "w": 8,
+ "h": 15,
+ "i": "4",
+ "type": "Widget",
+ "widget": {
+ "title": "OOM Kill Count",
+ "tips": "Out-of-memory kills for this app version"
+ },
+ "graph": {
+ "type": "Bar",
+ "showBackground": true
+ },
+ "expressions": [
+ "meter_ios_instance_background_oom_kill_count"
+ ]
+ },
+ {
+ "x": 0,
+ "y": 30,
+ "w": 8,
+ "h": 15,
+ "i": "5",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound HTTP Load (calls / min)",
+ "tips": "Client-side outbound HTTP requests from this app version"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "service_instance_cpm"
+ ],
+ "associate": [
+ {
+ "widgetId": "12-0-6"
+ },
+ {
+ "widgetId": "12-0-7"
+ }
+ ]
+ },
+ {
+ "x": 8,
+ "y": 30,
+ "w": 8,
+ "h": 15,
+ "i": "6",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound HTTP Avg Response Time (ms)",
+ "tips": "Client-observed latency from this app version"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "service_instance_resp_time"
+ ],
+ "associate": [
+ {
+ "widgetId": "12-0-5"
+ },
+ {
+ "widgetId": "12-0-7"
+ }
+ ]
+ },
+ {
+ "x": 16,
+ "y": 30,
+ "w": 8,
+ "h": 15,
+ "i": "7",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound HTTP Success Rate (%)",
+ "tips": "Success rate from this app version"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "service_instance_sla/100"
+ ],
+ "associate": [
+ {
+ "widgetId": "12-0-5"
+ },
+ {
+ "widgetId": "12-0-6"
+ }
+ ]
+ },
+ {
+ "x": 0,
+ "y": 45,
+ "w": 8,
+ "h": 15,
+ "i": "8",
+ "type": "Widget",
+ "widget": {
+ "title": "Peak Memory (bytes)",
+ "tips": "Daily peak memory for this app version"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "meter_ios_instance_peak_memory"
+ ]
+ },
+ {
+ "x": 8,
+ "y": 45,
+ "w": 8,
+ "h": 15,
+ "i": "9",
+ "type": "Widget",
+ "widget": {
+ "title": "Avg Network Transfer (bytes)",
+ "tips": "Average per-device daily network transfer for this app version"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "meter_ios_instance_wifi_download",
+ "meter_ios_instance_wifi_upload",
+ "meter_ios_instance_cellular_download",
+ "meter_ios_instance_cellular_upload"
+ ],
+ "metricConfig": [
+ {
+ "label": "WiFi Down"
+ },
+ {
+ "label": "WiFi Up"
+ },
+ {
+ "label": "Cell Down"
+ },
+ {
+ "label": "Cell Up"
+ }
+ ]
+ },
+ {
+ "x": 16,
+ "y": 45,
+ "w": 8,
+ "h": 15,
+ "i": "10",
+ "type": "Widget",
+ "widget": {
+ "title": "Scroll Hitch Ratio",
+ "tips": "Average scroll jank ratio for this app version"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "meter_ios_instance_scroll_hitch_ratio"
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Log",
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 24,
+ "h": 60,
+ "i": "0",
+ "type": "Log"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "layer": "IOS",
+ "entity": "ServiceInstance",
+ "name": "iOS-Instance",
+ "id": "iOS-Instance",
+ "isRoot": false
+ }
+ }
+]
diff --git a/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-root.json b/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-root.json
new file mode 100644
index 0000000000..3a940ed754
--- /dev/null
+++ b/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-root.json
@@ -0,0 +1,75 @@
+[
+ {
+ "id": "iOS-Root",
+ "configuration": {
+ "children": [
+ {
+ "x": 0,
+ "y": 2,
+ "w": 24,
+ "h": 46,
+ "i": "0",
+ "type": "Widget",
+ "graph": {
+ "type": "ServiceList",
+ "dashboardName": "iOS-Service",
+ "fontSize": 12,
+ "showXAxis": false,
+ "showYAxis": false,
+ "showGroup": true
+ },
+ "expressions": [
+ "avg(meter_ios_app_launch_time_percentile{p='50'})",
+ "avg(meter_ios_hang_time_sum)",
+ "avg(meter_ios_foreground_abnormal_exit_count+meter_ios_background_abnormal_exit_count)",
+ "avg(service_cpm)"
+ ],
+ "subExpressions": [
+ "meter_ios_app_launch_time_percentile{p='50'}",
+ "meter_ios_hang_time_sum",
+ "meter_ios_foreground_abnormal_exit_count+meter_ios_background_abnormal_exit_count",
+ "service_cpm"
+ ],
+ "metricConfig": [
+ {
+ "label": "Launch Time P50",
+ "unit": "ms"
+ },
+ {
+ "label": "Hang Time Sum",
+ "unit": "ms"
+ },
+ {
+ "label": "Crashes"
+ },
+ {
+ "label": "Outbound Load",
+ "unit": "calls / min"
+ }
+ ]
+ },
+ {
+ "x": 0,
+ "y": 0,
+ "w": 24,
+ "h": 2,
+ "i": "100",
+ "type": "Text",
+ "graph": {
+ "fontColor": "theme",
+ "backgroundColor": "theme",
+ "content": "iOS/iPadOS app monitoring via OpenTelemetry Swift SDK. Outbound HTTP traffic metrics (client-side requests to remote servers) and daily MetricKit device stats.",
+ "fontSize": 14,
+ "textAlign": "left",
+ "url": "https://skywalking.apache.org/docs/main/next/en/setup/backend/backend-ios-monitoring/"
+ }
+ }
+ ],
+ "id": "iOS-Root",
+ "layer": "IOS",
+ "entity": "All",
+ "name": "iOS-Root",
+ "isRoot": true
+ }
+ }
+]
diff --git a/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-service.json b/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-service.json
new file mode 100644
index 0000000000..5da5c12c6e
--- /dev/null
+++ b/oap-server/server-starter/src/main/resources/ui-initialized-templates/ios/ios-service.json
@@ -0,0 +1,446 @@
+[
+ {
+ "id": "iOS-Service",
+ "configuration": {
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 24,
+ "h": 64,
+ "i": "4",
+ "type": "Tab",
+ "children": [
+ {
+ "name": "Overview",
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 12,
+ "h": 15,
+ "i": "0",
+ "type": "Widget",
+ "widget": {
+ "title": "App Launch Time Percentile (ms)",
+ "tips": "Distribution of app launch time (time to first draw) across devices from MetricKit"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "relabels(meter_ios_app_launch_time_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='P50,P75,P90,P95,P99')"
+ ],
+ "metricConfig": [
+ {
+ "label": "P50, P75, P90, P95, P99"
+ }
+ ]
+ },
+ {
+ "x": 12,
+ "y": 0,
+ "w": 12,
+ "h": 15,
+ "i": "1",
+ "type": "Widget",
+ "widget": {
+ "title": "Hang Time Percentile (ms)",
+ "tips": "Distribution of UI hang (freeze) duration across devices from MetricKit"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "relabels(meter_ios_hang_time_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='P50,P75,P90,P95,P99')"
+ ],
+ "metricConfig": [
+ {
+ "label": "P50, P75, P90, P95, P99"
+ }
+ ]
+ },
+ {
+ "x": 0,
+ "y": 15,
+ "w": 8,
+ "h": 15,
+ "i": "2",
+ "type": "Widget",
+ "widget": {
+ "title": "Hang Time Sum (ms)",
+ "tips": "Total UI hang time across all devices from MetricKit"
+ },
+ "graph": {
+ "type": "Bar",
+ "showBackground": true
+ },
+ "metricConfig": [
+ {
+ "unit": "ms"
+ }
+ ],
+ "expressions": [
+ "meter_ios_hang_time_sum"
+ ]
+ },
+ {
+ "x": 8,
+ "y": 15,
+ "w": 8,
+ "h": 15,
+ "i": "3",
+ "type": "Widget",
+ "widget": {
+ "title": "Abnormal Exit Count (Crashes)",
+ "tips": "Daily abnormal exits from MetricKit (foreground + background), summed across devices"
+ },
+ "graph": {
+ "type": "Bar",
+ "showBackground": true
+ },
+ "expressions": [
+ "meter_ios_foreground_abnormal_exit_count",
+ "meter_ios_background_abnormal_exit_count"
+ ],
+ "metricConfig": [
+ {
+ "label": "Foreground"
+ },
+ {
+ "label": "Background"
+ }
+ ]
+ },
+ {
+ "x": 16,
+ "y": 15,
+ "w": 8,
+ "h": 15,
+ "i": "4",
+ "type": "Widget",
+ "widget": {
+ "title": "OOM Kill Count",
+ "tips": "Daily out-of-memory kills from MetricKit, summed across devices"
+ },
+ "graph": {
+ "type": "Bar",
+ "showBackground": true
+ },
+ "expressions": [
+ "meter_ios_background_oom_kill_count"
+ ]
+ },
+ {
+ "x": 0,
+ "y": 30,
+ "w": 8,
+ "h": 15,
+ "i": "5",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound HTTP Load (calls / min)",
+ "tips": "Client-side outbound HTTP requests from this iOS app to remote servers"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "service_cpm"
+ ],
+ "associate": [
+ {
+ "widgetId": "4-0-6"
+ },
+ {
+ "widgetId": "4-0-7"
+ }
+ ]
+ },
+ {
+ "x": 8,
+ "y": 30,
+ "w": 8,
+ "h": 15,
+ "i": "6",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound HTTP Avg Response Time (ms)",
+ "tips": "Average latency of outbound HTTP requests as observed by the iOS client"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "service_resp_time"
+ ],
+ "associate": [
+ {
+ "widgetId": "4-0-5"
+ },
+ {
+ "widgetId": "4-0-7"
+ }
+ ]
+ },
+ {
+ "x": 16,
+ "y": 30,
+ "w": 8,
+ "h": 15,
+ "i": "7",
+ "type": "Widget",
+ "widget": {
+ "title": "Outbound HTTP Success Rate (%)",
+ "tips": "Percentage of outbound HTTP requests with status code < 400"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "service_sla/100"
+ ],
+ "associate": [
+ {
+ "widgetId": "4-0-5"
+ },
+ {
+ "widgetId": "4-0-6"
+ }
+ ]
+ },
+ {
+ "x": 0,
+ "y": 45,
+ "w": 8,
+ "h": 15,
+ "i": "8",
+ "type": "Widget",
+ "widget": {
+ "title": "Peak Memory (bytes)",
+ "tips": "Daily peak memory from MetricKit, max across devices"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "meter_ios_peak_memory"
+ ]
+ },
+ {
+ "x": 8,
+ "y": 45,
+ "w": 8,
+ "h": 15,
+ "i": "9",
+ "type": "Widget",
+ "widget": {
+ "title": "Avg Network Transfer (bytes)",
+ "tips": "Average per-device daily network transfer from MetricKit"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "meter_ios_wifi_download",
+ "meter_ios_wifi_upload",
+ "meter_ios_cellular_download",
+ "meter_ios_cellular_upload"
+ ],
+ "metricConfig": [
+ {
+ "label": "WiFi Down"
+ },
+ {
+ "label": "WiFi Up"
+ },
+ {
+ "label": "Cell Down"
+ },
+ {
+ "label": "Cell Up"
+ }
+ ]
+ },
+ {
+ "x": 16,
+ "y": 45,
+ "w": 8,
+ "h": 15,
+ "i": "10",
+ "type": "Widget",
+ "widget": {
+ "title": "Scroll Hitch Ratio",
+ "tips": "Average scroll jank ratio from MetricKit"
+ },
+ "graph": {
+ "type": "Line",
+ "step": false,
+ "smooth": false,
+ "showSymbol": true,
+ "showXAxis": true,
+ "showYAxis": true
+ },
+ "expressions": [
+ "meter_ios_scroll_hitch_ratio"
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Instance",
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 24,
+ "h": 60,
+ "i": "0",
+ "type": "Widget",
+ "widget": {
+ "title": "App Version Breakdown",
+ "tips": "Each instance represents a client app version (service.version). Compare performance across app releases."
+ },
+ "graph": {
+ "type": "InstanceList",
+ "dashboardName": "iOS-Instance",
+ "fontSize": 12
+ },
+ "expressions": [
+ "avg(meter_ios_instance_app_launch_time_percentile{p='50'})",
+ "avg(meter_ios_instance_hang_time_sum)",
+ "avg(meter_ios_instance_foreground_abnormal_exit_count+meter_ios_instance_background_abnormal_exit_count)",
+ "avg(service_instance_cpm)"
+ ],
+ "subExpressions": [
+ "meter_ios_instance_app_launch_time_percentile{p='50'}",
+ "meter_ios_instance_hang_time_sum",
+ "meter_ios_instance_foreground_abnormal_exit_count+meter_ios_instance_background_abnormal_exit_count",
+ "service_instance_cpm"
+ ],
+ "metricConfig": [
+ {
+ "label": "Launch Time P50",
+ "unit": "ms"
+ },
+ {
+ "label": "Hang Time Sum",
+ "unit": "ms"
+ },
+ {
+ "label": "Crashes"
+ },
+ {
+ "label": "Outbound Load",
+ "unit": "calls / min"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Endpoint",
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 24,
+ "h": 60,
+ "i": "0",
+ "type": "Widget",
+ "widget": {
+ "title": "Remote Endpoints",
+ "tips": "Remote domains this iOS app calls via URLSession. Click a row to drill into per-endpoint outbound traffic."
+ },
+ "graph": {
+ "type": "EndpointList",
+ "dashboardName": "iOS-Endpoint",
+ "fontSize": 12,
+ "showXAxis": false,
+ "showYAxis": false
+ },
+ "expressions": [
+ "avg(endpoint_cpm)",
+ "avg(endpoint_sla)/100",
+ "avg(endpoint_resp_time)"
+ ],
+ "subExpressions": [
+ "endpoint_cpm",
+ "endpoint_sla/100",
+ "endpoint_resp_time"
+ ],
+ "metricConfig": [
+ {
+ "label": "Outbound Load",
+ "unit": "calls / min"
+ },
+ {
+ "label": "Outbound Success Rate",
+ "unit": "%"
+ },
+ {
+ "label": "Outbound Latency",
+ "unit": "ms"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Log",
+ "children": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 24,
+ "h": 60,
+ "i": "0",
+ "type": "Log"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "layer": "IOS",
+ "entity": "Service",
+ "name": "iOS-Service",
+ "isRoot": false
+ }
+ }
+]
diff --git a/oap-server/server-starter/src/main/resources/ui-initialized-templates/menu.yaml b/oap-server/server-starter/src/main/resources/ui-initialized-templates/menu.yaml
index feb37405af..a5eca99cfb 100644
--- a/oap-server/server-starter/src/main/resources/ui-initialized-templates/menu.yaml
+++ b/oap-server/server-starter/src/main/resources/ui-initialized-templates/menu.yaml
@@ -242,6 +242,16 @@ menus:
description: Provide Flink monitoring through OpenTelemetry's Prometheus Receiver.
documentLink: https://skywalking.apache.org/docs/main/next/en/setup/backend/backend-flink-monitoring/
i18nKey: data_processing_engine_flink
+ - title: Mobile
+ icon: mobile
+ description: Mobile application monitoring via OpenTelemetry SDKs.
+ i18nKey: mobile
+ menus:
+ - title: iOS
+ layer: IOS
+ description: iOS/iPadOS app monitoring via OpenTelemetry Swift SDK. Provides HTTP performance, MetricKit daily stats, and crash diagnostics.
+ documentLink: https://skywalking.apache.org/docs/main/next/en/setup/backend/backend-ios-monitoring/
+ i18nKey: mobile_ios
- title: GenAI
icon: gen_ai
description: Generative AI (GenAI) observability provides comprehensive monitoring and performance insights for various AI services and large language models (LLM).
diff --git a/skywalking-ui b/skywalking-ui
index 4e8a7576ac..a6be0e0e0d 160000
--- a/skywalking-ui
+++ b/skywalking-ui
@@ -1 +1 @@
-Subproject commit 4e8a7576ac03b7cb87288ccf4ad0503db4082302
+Subproject commit a6be0e0e0de62b3abed4f512f67ce74486ed158a
diff --git a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener b/test/e2e-v2/cases/ios/docker-compose.yml
similarity index 55%
copy from oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
copy to test/e2e-v2/cases/ios/docker-compose.yml
index b18e6ee2d9..9316cbd312 100644
--- a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
+++ b/test/e2e-v2/cases/ios/docker-compose.yml
@@ -1,4 +1,3 @@
-#
# 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.
@@ -13,6 +12,29 @@
# 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.
-#
-org.apache.skywalking.oap.analyzer.ios.listener.IOSLayerSpanListener
-org.apache.skywalking.oap.analyzer.ios.listener.IOSMetricKitSpanListener
+
+services:
+ oap:
+ extends:
+ file: ../../script/docker-compose/base-compose.yml
+ service: oap
+ ports:
+ - 12800
+ environment:
+ SW_OTEL_RECEIVER: default
+ SW_OTEL_RECEIVER_ENABLED_HANDLERS: "otlp-traces,otlp-logs"
+ SW_OTEL_RECEIVER_ENABLED_OTEL_METRICS_RULES: "ios/ios-metrickit,ios/ios-metrickit-instance"
+ SW_RECEIVER_ZIPKIN: default
+ SW_QUERY_ZIPKIN: default
+ SW_LOG_LAL_FILES: "ios-metrickit"
+ SW_LOG_ANALYZER: default
+
+ banyandb:
+ extends:
+ file: ../../script/docker-compose/base-compose.yml
+ service: banyandb
+ ports:
+ - 17912
+
+networks:
+ e2e:
diff --git a/test/e2e-v2/cases/ios/e2e.yaml b/test/e2e-v2/cases/ios/e2e.yaml
new file mode 100644
index 0000000000..fbac4c8e94
--- /dev/null
+++ b/test/e2e-v2/cases/ios/e2e.yaml
@@ -0,0 +1,74 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+setup:
+ env: compose
+ file: docker-compose.yml
+ timeout: 20m
+ init-system-environment: ../../script/env
+ steps:
+ - name: set PATH
+ command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
+ - name: install yq
+ command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq
+ - name: install swctl
+ command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl
+ - name: send iOS MetricKit crash diagnostic log (OTLP)
+ command: |
+ for i in 1 2 3 4 5; do
+ TS_NS=$(date +%s)000000000
+ curl -sS --max-time 10 -X POST -H 'Content-Type: application/json' \
+ http://${oap_host}:${oap_12800}/v1/logs \
+ -d "{\"resourceLogs\":[{\"resource\":{\"attributes\":[{\"key\":\"service.name\",\"value\":{\"stringValue\":\"MyiOSApp\"}},{\"key\":\"service.version\",\"value\":{\"stringValue\":\"2.1.0\"}},{\"key\":\"os.name\",\"value\":{\"stringValue\":\"iOS\"}},{\"key\":\"os.version\",\"value\":{\"stringValue\":\"17.4.1\"}},{\"key\":\"device.model.identifier\",\"value\":{\"stringValue\":\"iPhone15,2\"}}]},\"scopeLogs\":[{\"scope\":{\"name\":\"MetricKit\"},\"logRecords\":[{\"timeUnixNano\": [...]
+ && break || sleep 5
+ done
+
+trigger:
+ action: http
+ interval: 3s
+ times: 10
+ url: http://${oap_host}:${oap_12800}/v1/traces
+ method: POST
+ headers:
+ Content-Type: application/json
+ body: '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"MyiOSApp"}},{"key":"service.version","value":{"stringValue":"2.1.0"}},{"key":"os.name","value":{"stringValue":"iOS"}},{"key":"os.version","value":{"stringValue":"17.4.1"}},{"key":"device.model.identifier","value":{"stringValue":"iPhone15,2"}},{"key":"telemetry.sdk.language","value":{"stringValue":"swift"}}]},"scopeSpans":[{"scope":{"name":"NSURLSession","version":"1.12.0"},"spans":[{"trace [...]
+
+verify:
+ retry:
+ count: 20
+ interval: 10s
+ cases:
+ # Verify iOS service registered with IOS layer
+ - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql service ly IOS
+ expected: expected/service.yml
+ # Verify outbound HTTP traffic metrics (from IOSHTTPSpanListener -> OAL)
+ - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression=service_cpm --service-name=MyiOSApp
+ expected: expected/metrics-has-value.yml
+ # Verify MetricKit MAL metrics (service-level, with service_instance_id label from aggregation)
+ - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression=meter_ios_foreground_abnormal_exit_count --service-name=MyiOSApp
+ expected: expected/metrics-has-value.yml
+ - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression=meter_ios_hang_time_sum --service-name=MyiOSApp
+ expected: expected/metrics-has-value.yml
+ # Verify domain-based endpoint and its metrics
+ - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql endpoint ls --service-name=MyiOSApp
+ expected: expected/endpoint.yml
+ - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression=endpoint_cpm --service-name=MyiOSApp --endpoint-name=api.example.com
+ expected: expected/metrics-has-value.yml
+ # Verify instance outbound traffic metrics
+ - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression=service_instance_cpm --service-name=MyiOSApp --instance-name=2.1.0
+ expected: expected/metrics-has-value.yml
+ # Verify MetricKit diagnostic log persisted via LAL (ios-metrickit rule)
+ - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql logs list --service-name=MyiOSApp
+ expected: expected/logs.yml
diff --git a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener b/test/e2e-v2/cases/ios/expected/endpoint.yml
similarity index 84%
copy from oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
copy to test/e2e-v2/cases/ios/expected/endpoint.yml
index b18e6ee2d9..985f26068f 100644
--- a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
+++ b/test/e2e-v2/cases/ios/expected/endpoint.yml
@@ -1,4 +1,3 @@
-#
# 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.
@@ -13,6 +12,8 @@
# 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.
-#
-org.apache.skywalking.oap.analyzer.ios.listener.IOSLayerSpanListener
-org.apache.skywalking.oap.analyzer.ios.listener.IOSMetricKitSpanListener
+
+{{- contains . }}
+- id: {{ notEmpty .id }}
+ name: api.example.com
+{{- end }}
diff --git a/test/e2e-v2/cases/ios/expected/logs.yml b/test/e2e-v2/cases/ios/expected/logs.yml
new file mode 100644
index 0000000000..be415d79c4
--- /dev/null
+++ b/test/e2e-v2/cases/ios/expected/logs.yml
@@ -0,0 +1,45 @@
+# 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.
+
+errorreason: null
+debuggingtrace: null
+logs:
+ {{- contains .logs }}
+ - servicename: MyiOSApp
+ serviceid: {{ notEmpty .serviceid }}
+ serviceinstancename: "2.1.0"
+ serviceinstanceid: {{ notEmpty .serviceinstanceid }}
+ endpointname: null
+ endpointid: null
+ traceid: null
+ timestamp: {{ gt .timestamp 0 }}
+ contenttype: TEXT
+ content: {{ notEmpty .content }}
+ tags:
+ {{- contains .tags }}
+ - key: diagnosticType
+ value: metrickit.diagnostic.crash
+ - key: exception.type
+ value: EXC_BAD_ACCESS
+ - key: exception.message
+ value: Could not access memory
+ - key: signal.name
+ value: SIGSEGV
+ - key: device.model
+ value: "iPhone15,2"
+ - key: os.version
+ value: "17.4.1"
+ {{- end }}
+ {{- end }}
diff --git a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener b/test/e2e-v2/cases/ios/expected/metrics-has-value-label.yml
similarity index 64%
copy from oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
copy to test/e2e-v2/cases/ios/expected/metrics-has-value-label.yml
index b18e6ee2d9..f2abc7d1b7 100644
--- a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
+++ b/test/e2e-v2/cases/ios/expected/metrics-has-value-label.yml
@@ -1,4 +1,3 @@
-#
# 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.
@@ -13,6 +12,23 @@
# 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.
-#
-org.apache.skywalking.oap.analyzer.ios.listener.IOSLayerSpanListener
-org.apache.skywalking.oap.analyzer.ios.listener.IOSMetricKitSpanListener
+
+debuggingtrace: null
+type: TIME_SERIES_VALUES
+results:
+ {{- contains .results }}
+ - metric:
+ labels:
+ {{- contains .metric.labels }}
+ - key: {{ notEmpty .key }}
+ value: {{ notEmpty .value }}
+ {{- end }}
+ values:
+ {{- contains .values }}
+ - id: {{ notEmpty .id }}
+ value: {{ notEmpty .value }}
+ traceid: null
+ owner: null
+ {{- end}}
+ {{- end}}
+error: null
diff --git a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener b/test/e2e-v2/cases/ios/expected/metrics-has-value.yml
similarity index 71%
copy from oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
copy to test/e2e-v2/cases/ios/expected/metrics-has-value.yml
index b18e6ee2d9..ced9ce2891 100644
--- a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
+++ b/test/e2e-v2/cases/ios/expected/metrics-has-value.yml
@@ -1,4 +1,3 @@
-#
# 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.
@@ -13,6 +12,19 @@
# 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.
-#
-org.apache.skywalking.oap.analyzer.ios.listener.IOSLayerSpanListener
-org.apache.skywalking.oap.analyzer.ios.listener.IOSMetricKitSpanListener
+
+debuggingtrace: null
+type: TIME_SERIES_VALUES
+results:
+ {{- contains .results }}
+ - metric:
+ labels: []
+ values:
+ {{- contains .values }}
+ - id: {{ notEmpty .id }}
+ value: {{ notEmpty .value }}
+ traceid: null
+ owner: null
+ {{- end}}
+ {{- end}}
+error: null
diff --git a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener b/test/e2e-v2/cases/ios/expected/service.yml
similarity index 84%
copy from oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
copy to test/e2e-v2/cases/ios/expected/service.yml
index b18e6ee2d9..cd0de86377 100644
--- a/oap-server/analyzer/ios-analyzer/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.trace.SpanListener
+++ b/test/e2e-v2/cases/ios/expected/service.yml
@@ -1,4 +1,3 @@
-#
# 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.
@@ -13,6 +12,13 @@
# 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.
-#
-org.apache.skywalking.oap.analyzer.ios.listener.IOSLayerSpanListener
-org.apache.skywalking.oap.analyzer.ios.listener.IOSMetricKitSpanListener
+
+{{- contains . }}
+- id: {{ b64enc "MyiOSApp" }}.1
+ name: MyiOSApp
+ group: ""
+ shortname: MyiOSApp
+ layers:
+ - IOS
+ normal: true
+{{- end }}