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:06 UTC
(skywalking) 01/02: Add iOS monitoring: IOS layer, SpanListeners, MAL/LAL rules
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'])