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'])