You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@felix.apache.org by ro...@apache.org on 2020/09/03 14:43:36 UTC

[felix-dev] 01/02: metrics/osgi: initial contribution

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

rombert pushed a commit to branch feature/contribute-osgi-metrics
in repository https://gitbox.apache.org/repos/asf/felix-dev.git

commit 8863470e1b194d6c5593b6fa955e7600e2b4c018
Author: Robert Munteanu <ro...@apache.org>
AuthorDate: Thu Sep 3 16:41:15 2020 +0200

    metrics/osgi: initial contribution
    
    The modules still refer to the Sling parent pom, but that should not
    be a blocker for the contribution (maybe for a first release).
---
 metrics/osgi/README.md                             |  70 ++++++
 metrics/osgi/collector/bnd.bnd                     |   2 +
 metrics/osgi/collector/pom.xml                     | 157 ++++++++++++++
 .../felix/metrics/osgi/BundleStartDuration.java    |  45 ++++
 .../felix/metrics/osgi/ServiceRestartCounter.java  |  39 ++++
 .../apache/felix/metrics/osgi/StartupMetrics.java  | 101 +++++++++
 .../felix/metrics/osgi/StartupMetricsListener.java |  38 ++++
 .../apache/felix/metrics/osgi/impl/Activator.java  |  51 +++++
 .../osgi/impl/BundleStartTimeCalculator.java       | 112 ++++++++++
 .../org/apache/felix/metrics/osgi/impl/Log.java    |  39 ++++
 .../osgi/impl/ServiceRestartCountCalculator.java   | 239 +++++++++++++++++++++
 .../osgi/impl/ServiceTrackerCustomizerAdapter.java |  33 +++
 .../metrics/osgi/impl/StartupTimeCalculator.java   | 136 ++++++++++++
 .../apache/felix/metrics/osgi/package-info.java    |  18 ++
 .../apache/felix/metrics/osgi/impl/AbstractIT.java | 126 +++++++++++
 .../osgi/impl/BundleStartTimeCalculatorTest.java   |  62 ++++++
 .../metrics/osgi/impl/HealthCheckSmokeIT.java      |  61 ++++++
 .../osgi/impl/ServiceRegistrationsTrackerTest.java |  76 +++++++
 .../impl/ServiceRestartCountCalculatorTest.java    | 192 +++++++++++++++++
 .../metrics/osgi/impl/SystemReadySmokeIT.java      |  47 ++++
 .../impl/WaitForResultsStartupMetricsListener.java |  45 ++++
 metrics/osgi/consumers/bnd.bnd                     |   1 +
 metrics/osgi/consumers/pom.xml                     | 104 +++++++++
 .../impl/dropwizard/DropwizardMetricsListener.java |  85 ++++++++
 .../impl/json/JsonWritingMetricsListener.java      |  95 ++++++++
 .../consumers/impl/log/LoggingMetricsListener.java |  92 ++++++++
 .../impl/json/JsonWritingMetricsListenerTest.java  |  78 +++++++
 metrics/osgi/pom.xml                               |  42 ++++
 28 files changed, 2186 insertions(+)

diff --git a/metrics/osgi/README.md b/metrics/osgi/README.md
new file mode 100644
index 0000000..c0a2e24
--- /dev/null
+++ b/metrics/osgi/README.md
@@ -0,0 +1,70 @@
+# Apache Felix OSGi Metrics
+
+The OSGi metrics module defines a simple mechanism to gather OSGi-related metrics for application startup.
+
+The module is split into two bundles:
+
+- _collector_ - a zero-dependencies bundle that uses the OSGi APIs to gather various metrics
+- _consumers_ - a single bundle that contains various consumers
+
+## Metric collection
+
+The metrics are collected by the `org.apache.felix.metrics.osgi.collector` bundle. This bundle requires no configuration and imports a minimal set of packages, to allow starting as early as possible.
+
+As soon as startup is completed the metrics are made available to consumers that implement the `StartupMetricsListener` interface. The metrics are published after an optional delay, to prevent on-off bounces in startup completion.
+
+Startup completion is delegated to either the `org.apache.felix.systemready` or the `org.apache.felix.healtchecks.api` bundles, which publish marker services once the system is considered ready.
+
+## Metric publication
+
+The `org.apache.felix.metrics.osgi.consumers` bundle contains three out-of-the-box implementation for publishing the metrics
+
+- DropWizard metrics using a `MetricRegistry`
+- JSON file written in the bundle data directory
+- Log entries using the SLF4j API
+
+### JSON metrics file sample
+
+The following (truncated) JSON file exemplifies how the metrics are written
+
+```json
+{
+  "application": {
+    "startTimeMillis": 1587469534671,
+    "startDurationMillis": 14635
+  },
+  "bundles": [
+    {
+      "symbolicName": "org.osgi.util.pushstream",
+      "startTimeMillis": 1587469535933,
+      "startDurationMillis": 0
+    },
+    {
+      "symbolicName": "org.apache.aries.util",
+      "startTimeMillis": 1587469535935,
+      "startDurationMillis": 0
+    },
+    {
+      "symbolicName": "org.apache.felix.configadmin",
+      "startTimeMillis": 1587469536313,
+      "startDurationMillis": 58
+    }
+  ],
+  "services": [
+    {
+      "identifier": "jmx.objectname=org.apache.sling.classloader:name=FSClassLoader,type=ClassLoader",
+      "restarts": 2
+    }
+  ]
+}
+```
+
+Similar metrics are reported through the other collectors.
+
+## Usage
+
+1. Add the `org.apache.felix/org.apache.felix.metrics.osgi.collector` bundle and ensure that
+   it starts as early as possible
+1. Add the `org.apache.felix/org.apache.felix.metrics.osgi.consumers` bundle.
+1. Add the required bundles, either Apache Felix SystemReady or Apache Felix Health Checks
+1. Start up the application
diff --git a/metrics/osgi/collector/bnd.bnd b/metrics/osgi/collector/bnd.bnd
new file mode 100644
index 0000000..6dee250
--- /dev/null
+++ b/metrics/osgi/collector/bnd.bnd
@@ -0,0 +1,2 @@
+Import-Package: org.slf4j;resolution:=optional, *
+DynamicImport-Package: org.slf4j
\ No newline at end of file
diff --git a/metrics/osgi/collector/pom.xml b/metrics/osgi/collector/pom.xml
new file mode 100644
index 0000000..192f732
--- /dev/null
+++ b/metrics/osgi/collector/pom.xml
@@ -0,0 +1,157 @@
+<?xml version="1.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. -->
+<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/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.sling</groupId>
+        <artifactId>sling-bundle-parent</artifactId>
+        <version>38</version>
+        <relativePath />
+    </parent>
+
+    <groupId>org.apache.felix</groupId>
+    <artifactId>org.apache.felix.metrics.osgi.collector</artifactId>
+    <version>0.1.0-SNAPSHOT</version>
+
+    <name>Apache Felix OSGi Metrics Collector</name>
+    <description> 
+        Collects metrics related to the OSGi framework and makes them available to consumers.
+    </description>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>biz.aQute.bnd</groupId>
+                <artifactId>bnd-baseline-maven-plugin</artifactId>
+                <configuration>
+                    <failOnMissing>false</failOnMissing>
+                </configuration>
+            </plugin>
+            <plugin>
+                <artifactId>maven-failsafe-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>integration-test</goal>
+                            <goal>verify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.annotation.versioning</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.annotation.bundle</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>osgi.core</artifactId>
+        </dependency>
+
+        <!-- note this is set to optional in bnd.bnd, to help the bundle start as soon as possible -->
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+        </dependency>
+
+        <!--  testing dependencies -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>3.3.3</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- IT dependencies -->
+        <dependency>
+            <groupId>org.ops4j.pax.exam</groupId>
+            <artifactId>pax-exam-container-native</artifactId>
+            <version>${pax-exam.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.ops4j.pax.exam</groupId>
+            <artifactId>pax-exam-junit4</artifactId>
+            <version>${pax-exam.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.ops4j.pax.exam</groupId>
+            <artifactId>pax-exam-link-mvn</artifactId>
+            <version>${pax-exam.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.ops4j.pax.url</groupId>
+            <artifactId>pax-url-aether</artifactId>
+            <version>${pax-url.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.framework</artifactId>
+            <version>6.0.3</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>javax.inject</groupId>
+            <artifactId>javax.inject</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.systemready</artifactId>
+            <version>0.4.2</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.healthcheck.api</artifactId>
+            <version>2.0.4</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.healthcheck.core</artifactId>
+            <version>2.0.8</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.healthcheck.generalchecks</artifactId>
+            <version>2.0.4</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+        </dependency>
+    </dependencies>
+
+    <properties>
+        <pax-exam.version>4.13.2</pax-exam.version>
+        <pax-url.version>2.6.2</pax-url.version>
+    </properties>
+</project>
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/BundleStartDuration.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/BundleStartDuration.java
new file mode 100644
index 0000000..214b47b
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/BundleStartDuration.java
@@ -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.
+ */
+package org.apache.felix.metrics.osgi;
+
+import java.time.Duration;
+import java.time.Instant;
+
+public final class BundleStartDuration {
+
+    private final String symbolicName;
+    private final Instant startingAt;
+    private final Duration startedAfter;
+
+    public BundleStartDuration(String symbolicName, Instant startingAt, Duration startedAfter) {
+        this.symbolicName = symbolicName;
+        this.startingAt = startingAt;
+        this.startedAfter = startedAfter;
+    }
+    
+    public String getSymbolicName() {
+        return symbolicName;
+    }
+
+    public Instant getStartingAt() {
+        return startingAt;
+    }
+    
+    public Duration getStartedAfter() {
+        return startedAfter;
+    }
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/ServiceRestartCounter.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/ServiceRestartCounter.java
new file mode 100644
index 0000000..8be29ef
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/ServiceRestartCounter.java
@@ -0,0 +1,39 @@
+/*
+ * 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.felix.metrics.osgi;
+
+public final class ServiceRestartCounter {
+    
+    private final String serviceIdentifier;
+    private final int serviceRestarts;
+
+    public ServiceRestartCounter(String serviceIdentifier, int serviceRestarts) {
+        this.serviceIdentifier = serviceIdentifier;
+        this.serviceRestarts = serviceRestarts;
+    }
+
+    /**
+     * @return a opaque service identifier, used for describing the service that has restarted
+     */
+    public String getServiceIdentifier() {
+        return serviceIdentifier;
+    }
+    
+    public int getServiceRestarts() {
+        return serviceRestarts;
+    }
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/StartupMetrics.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/StartupMetrics.java
new file mode 100644
index 0000000..d878766
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/StartupMetrics.java
@@ -0,0 +1,101 @@
+/*
+ * 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.felix.metrics.osgi;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Provides metrics about the OSGi framework startup and associated services
+ *
+ * <p>The calculation of the application being "ready" is based on the Apache Felix SystemReady bundle and
+ * requires a proper configuration of all checks.</p>
+ */
+public final class StartupMetrics {
+    
+    public static final class Builder {
+        
+        private StartupMetrics startupMetrics = new StartupMetrics();
+        
+        public static Builder withJvmStartup(Instant jvmStartup) {
+            Builder builder = new Builder();
+            builder.startupMetrics.jvmStartup = jvmStartup;
+            return builder;
+        }
+        
+        public Builder withStartupTime(Duration startupTime) {
+            startupMetrics.startupTime = startupTime;
+            return this;
+        }
+        
+        public Builder withBundleStartDurations(List<BundleStartDuration> bundleStartDurations) {
+            startupMetrics.bundleStartDurations = Collections.unmodifiableList(bundleStartDurations);
+            return this;
+        }
+        
+        public Builder withServiceRestarts(List<ServiceRestartCounter> serviceRestarts) {
+            startupMetrics.serviceRestarts = Collections.unmodifiableList(serviceRestarts);
+            return this;
+        }
+        
+        public StartupMetrics build() {
+            return startupMetrics;
+        }
+    }
+
+    private Instant jvmStartup;
+    private Duration startupTime;
+    private List<BundleStartDuration> bundleStartDurations;
+    private List<ServiceRestartCounter> serviceRestarts;
+ 
+    private StartupMetrics() { }
+
+    /**
+     * Returns the instant when the JVM has started
+     * 
+     * <p>Note that this is different from the OSGi startup process, and may lead to unexpected results if the
+     * OSGi framework starts considerably later compared to the JVM.</p>
+     * 
+     * @return the instant when the JVM has started
+     */
+    public Instant getJvmStartup() {
+        return jvmStartup;
+    }
+    
+    /**
+     * @return the time between the {@link #getJvmStartup()} and the application being ready
+     */
+    public Duration getStartupTime() {
+        return startupTime;
+    }
+    
+    /**
+     * @return all bundle start durations
+     */
+    public List<BundleStartDuration> getBundleStartDurations() {
+        return bundleStartDurations;
+    }
+    
+    /**
+     * @return tracked services with at least one restart
+     */
+    public List<ServiceRestartCounter> getServiceRestarts() {
+        return serviceRestarts;
+    }
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/StartupMetricsListener.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/StartupMetricsListener.java
new file mode 100644
index 0000000..07022d2
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/StartupMetricsListener.java
@@ -0,0 +1,38 @@
+/*
+ * 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.felix.metrics.osgi;
+
+import org.osgi.annotation.versioning.ConsumerType;
+
+/**
+ * A listener that is notified of the startup metrics
+ * 
+ * <p>The time of the notification can be delayed after the actual application start, as
+ * the implementation may choose to delay it to ensure that the startup is not affected
+ * by e.g. bouncing services.</p>
+ * 
+ * <p>Listeners that register after the application startup will receive a notification anyway.</p>
+ *
+ */
+@ConsumerType
+public interface StartupMetricsListener {
+    
+    /**
+     * @param metrics the startup metrics
+     */
+    void onStartupComplete(StartupMetrics metrics);
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/Activator.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/Activator.java
new file mode 100644
index 0000000..abce910
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/Activator.java
@@ -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.
+ */
+package org.apache.felix.metrics.osgi.impl;
+
+import org.osgi.annotation.bundle.Header;
+import org.osgi.framework.BundleActivator;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+
+@Header(name = Constants.BUNDLE_ACTIVATOR, value = "${@class}")
+// avoid dependency to SCR so we can start early on
+public class Activator implements BundleActivator {
+
+    private BundleStartTimeCalculator bstc;
+    private StartupTimeCalculator stc;
+    private ServiceRestartCountCalculator srcc;
+
+    @Override
+    public void start(BundleContext context) throws Exception {
+        
+        bstc = new BundleStartTimeCalculator(context.getBundle().getBundleId());
+        context.addBundleListener(bstc);
+
+        srcc = new ServiceRestartCountCalculator();
+        context.addServiceListener(srcc);
+
+        stc = new StartupTimeCalculator(context, bstc, srcc);
+    }
+
+    @Override
+    public void stop(BundleContext context) throws Exception {
+        stc.close();
+        context.removeServiceListener(srcc);
+        context.removeBundleListener(bstc);
+    }
+
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/BundleStartTimeCalculator.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/BundleStartTimeCalculator.java
new file mode 100644
index 0000000..449497f
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/BundleStartTimeCalculator.java
@@ -0,0 +1,112 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.felix.metrics.osgi.BundleStartDuration;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleEvent;
+import org.osgi.framework.Constants;
+import org.osgi.framework.SynchronousBundleListener;
+
+public class BundleStartTimeCalculator implements SynchronousBundleListener {
+
+    private Map<Long, StartTime> bundleToStartTime = new HashMap<>();
+    private Clock clock = Clock.systemUTC();
+    private final long ourBundleId;
+
+    public BundleStartTimeCalculator(long ourBundleId) {
+        this.ourBundleId = ourBundleId;
+    }
+
+    @Override
+    public void bundleChanged(BundleEvent event) {
+        Bundle bundle = event.getBundle();
+        
+        // this bundle is already starting by the time this is invoked. We also can't get proper timing
+        // from the framework bundle
+        
+        if ( bundle.getBundleId() == Constants.SYSTEM_BUNDLE_ID 
+                || bundle.getBundleId() == ourBundleId ) {
+            return;
+        }
+        
+        synchronized (bundleToStartTime) {
+
+            switch (event.getType()) {
+                case BundleEvent.STARTING:
+                    bundleToStartTime.put(bundle.getBundleId(), new StartTime(bundle.getSymbolicName(), clock.millis()));
+                    break;
+    
+                case BundleEvent.STARTED:
+                    StartTime startTime = bundleToStartTime.get(bundle.getBundleId());
+                    if ( startTime == null ) {
+                        Log.debug(getClass(), "No previous data for started bundle {}/{}", new Object[] { bundle.getBundleId(), bundle.getSymbolicName() });
+                        return;
+                    }
+                    startTime.started(clock.millis());
+                    break;
+                
+                default: // nothing to do here
+                    break;
+            }
+        }
+    }
+    
+    public List<BundleStartDuration> getBundleStartDurations() {
+        
+        synchronized (bundleToStartTime) {
+            return bundleToStartTime.values().stream()
+                .map( StartTime::toBundleStartDuration )
+                .collect( Collectors.toList() );                    
+        }
+    }
+
+    class StartTime {
+        private final String bundleSymbolicName;
+        private long startingTimestamp;
+        private long startedTimestamp;
+        
+        public StartTime(String bundleSymbolicName, long startingTimestamp) {
+            this.bundleSymbolicName = bundleSymbolicName;
+            this.startingTimestamp = startingTimestamp;
+        }
+
+        public long getDuration() {
+            return startedTimestamp - startingTimestamp;
+        }
+        
+        public String getBundleSymbolicName() {
+            return bundleSymbolicName;
+        }
+
+        public void started(long startedTimestamp) {
+            this.startedTimestamp = startedTimestamp;
+        }
+        
+        public BundleStartDuration toBundleStartDuration() {
+            return new BundleStartDuration(bundleSymbolicName, Instant.ofEpochMilli(startingTimestamp), Duration.ofMillis(startedTimestamp - startingTimestamp));
+        }
+    }
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/Log.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/Log.java
new file mode 100644
index 0000000..2c3e1ad
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/Log.java
@@ -0,0 +1,39 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Delegates to slf4j if available, otherwise silently fails 
+ *
+ */
+public abstract class Log {
+    public static void debug(Class<?> caller, String message, Object... args) {
+        try {
+            Logger logger = LoggerFactory.getLogger(caller);
+            logger.debug(message, args);
+        } catch ( NoClassDefFoundError e ) {
+            // not available, just carry on
+        }
+    }
+
+    private Log() {
+        
+    }
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/ServiceRestartCountCalculator.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/ServiceRestartCountCalculator.java
new file mode 100644
index 0000000..bf6abbd
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/ServiceRestartCountCalculator.java
@@ -0,0 +1,239 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.apache.felix.metrics.osgi.ServiceRestartCounter;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceEvent;
+import org.osgi.framework.ServiceListener;
+
+public class ServiceRestartCountCalculator implements ServiceListener {
+
+    private static final String[] GENERAL_IDENTIFIER_PROPERTIES = new String[] { Constants.SERVICE_PID, "component.name", "jmx.objectname" };
+    private static final Map<String, Collection<String>> SPECIFIC_IDENTIFIER_PROPERTIES = new HashMap<>();
+    static {
+        SPECIFIC_IDENTIFIER_PROPERTIES.put("org.apache.sling.commons.metrics.Gauge", Arrays.asList("name"));
+        SPECIFIC_IDENTIFIER_PROPERTIES.put("org.apache.sling.spi.resource.provider.ResourceProvider", Arrays.asList("provider.root"));
+        SPECIFIC_IDENTIFIER_PROPERTIES.put("org.apache.sling.servlets.post.PostOperation", Arrays.asList("sling.post.operation"));
+        SPECIFIC_IDENTIFIER_PROPERTIES.put("javax.servlet.Servlet", Arrays.asList("felix.webconsole.label"));
+        SPECIFIC_IDENTIFIER_PROPERTIES.put("org.apache.felix.inventory.InventoryPrinter", Arrays.asList("felix.inventory.printer.name"));
+    }
+    
+    private final Map<ServiceIdentifier, ServiceRegistrationsTracker> registrations = new HashMap<>();
+    private final Map<String, Integer> unidentifiedRegistrationsByClassName = new HashMap<>();
+    
+    @Override
+    public void serviceChanged(ServiceEvent event) {
+        
+        if ( shouldIgnore(event) ) 
+            return;
+
+        ServiceIdentifier id = tryFindIdFromGeneralProperties(event);
+        if ( id == null )
+            id = tryFindIdFromSpecificProperties(event);
+        
+        if ( id == null ) {
+            logUnknownService(event);
+            if ( event.getType() == ServiceEvent.UNREGISTERING )
+                recordUnknownServiceUnregistration(event);
+            return;
+        }
+
+        ServiceRegistrationsTracker tracker;
+        synchronized (registrations) {
+            
+            if ( event.getType() == ServiceEvent.REGISTERED ) {
+                tracker = registrations.computeIfAbsent(id, ServiceRegistrationsTracker::new);
+                tracker.registered();
+            } else if ( event.getType() == ServiceEvent.UNREGISTERING ) {
+                
+                tracker = registrations.get(id);
+                if (tracker == null) {
+                    Log.debug(getClass(), "Service with identifier {} was unregistered, but no previous registration data was found", id);
+                    return;
+                }
+                tracker.unregistered();
+            }
+        }
+    }
+
+    private boolean shouldIgnore(ServiceEvent event) {
+        
+        return event.getType() != ServiceEvent.REGISTERED && event.getType() != ServiceEvent.UNREGISTERING;
+    }
+
+    private ServiceIdentifier tryFindIdFromGeneralProperties(ServiceEvent event) {
+        for ( String identifierProp : GENERAL_IDENTIFIER_PROPERTIES ) {
+            Object identifierVal = event.getServiceReference().getProperty(identifierProp);
+            if ( identifierVal != null )
+                return new ServiceIdentifier(identifierProp, identifierVal.toString() );
+        }
+        
+        return null;
+    }
+
+    private ServiceIdentifier tryFindIdFromSpecificProperties(ServiceEvent event) {
+        for ( Map.Entry<String, Collection<String>> entry : SPECIFIC_IDENTIFIER_PROPERTIES.entrySet() ) {
+            String[] classNames = (String[]) event.getServiceReference().getProperty(Constants.OBJECTCLASS);
+            for ( String className : classNames ) {
+                if ( entry.getKey().equals(className) ) {
+                    StringBuilder propKey = new StringBuilder();
+                    StringBuilder propValue = new StringBuilder();
+                    
+                    for ( String idPropName : entry.getValue() ) {
+                        Object idPropVal = event.getServiceReference().getProperty(idPropName);
+                        if ( idPropVal != null ) {
+                            propKey.append(idPropName).append('~');
+                            propValue.append(idPropVal).append('~');
+                        }
+                    }
+                    
+                    if ( propKey.length() != 0 ) {
+                        propKey.deleteCharAt(propKey.length() - 1);
+                        propValue.deleteCharAt(propValue.length() - 1);
+                        ServiceIdentifier id = new ServiceIdentifier(propKey.toString(), propValue.toString());
+                        id.setAdditionalInfo(Constants.OBJECTCLASS + "=" + Arrays.toString(classNames));
+                        return id;
+                    }
+                }
+            }
+        }
+        
+        return null;
+    }
+
+    private void logUnknownService(ServiceEvent event) {
+        if ( event.getType() == ServiceEvent.UNREGISTERING ) {
+            Map<String, Object> props = new HashMap<>();
+            for ( String propertyName : event.getServiceReference().getPropertyKeys() ) {
+                Object propVal = event.getServiceReference().getProperty(propertyName);
+                if ( propVal.getClass() == String[].class )
+                    propVal = Arrays.toString((String[]) propVal);
+                props.put(propertyName, propVal);
+            }
+
+            Log.debug(getClass(), "Ignoring unregistration of service with props {}, as it has none of identifier properties {}", props, Arrays.toString(GENERAL_IDENTIFIER_PROPERTIES));
+        }
+    }
+
+    private void recordUnknownServiceUnregistration(ServiceEvent event) {
+        String[] classNames = (String[]) event.getServiceReference().getProperty(Constants.OBJECTCLASS);
+        synchronized (unidentifiedRegistrationsByClassName) {
+            for ( String className : classNames )
+                unidentifiedRegistrationsByClassName.compute(className, (k,v) -> v == null ? 1 : ++v);
+        }
+    }
+    
+    // visible for testing
+    Map<ServiceIdentifier, ServiceRegistrationsTracker> getRegistrations() {
+        synchronized (registrations) {
+            return new HashMap<>(registrations);
+        }
+    }
+    
+    // visible for testing
+    Map<String, Integer> getUnidentifiedRegistrationsByClassName() {
+        synchronized (unidentifiedRegistrationsByClassName) {
+            return unidentifiedRegistrationsByClassName;
+        }
+    }
+    
+    public List<ServiceRestartCounter> getServiceRestartCounters() {
+        synchronized (registrations) {
+            return registrations.values().stream()
+                .filter( r -> r.restartCount() > 0)
+                .map( ServiceRegistrationsTracker::toServiceRestartCounter )
+                .collect(Collectors.toList());
+        }
+    }
+    
+    static class ServiceIdentifier {
+        private String key;
+        private String value;
+        private String additionalInfo;
+
+        public ServiceIdentifier(String key, String value) {
+            this.key = key;
+            this.value = value;
+        }
+        
+        public void setAdditionalInfo(String additionalInfo) {
+            this.additionalInfo = additionalInfo;
+        }
+
+        @Override
+        public int hashCode() {
+            
+            return Objects.hash(key, value);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj)
+                return true;
+            if (obj == null)
+                return false;
+            if (getClass() != obj.getClass())
+                return false;
+            ServiceIdentifier other = (ServiceIdentifier) obj;
+            
+            return Objects.equals(key, other.key) && Objects.equals(value, other.value);
+        }
+        
+        @Override
+        public String toString() {
+            return this.key + "=" + this.value + ( additionalInfo != null ? "(" + additionalInfo + ")" : "") ;
+        }
+    }
+    
+    static class ServiceRegistrationsTracker {
+        private final ServiceIdentifier id;
+        private int registrationCount;
+        private int unregistrationCount;
+        
+        public ServiceRegistrationsTracker(ServiceIdentifier id) {
+            this.id = id;
+        }
+        
+        public void registered() {
+            this.registrationCount++;
+        }
+        
+        public void unregistered() {
+            this.unregistrationCount++;
+        }
+        
+        public int restartCount() {
+            if ( unregistrationCount == 0 )
+                return 0;
+            
+            return registrationCount - 1;
+        }
+        
+        public ServiceRestartCounter toServiceRestartCounter() {
+            return new ServiceRestartCounter(id.toString(), restartCount());
+        }
+    }
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/ServiceTrackerCustomizerAdapter.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/ServiceTrackerCustomizerAdapter.java
new file mode 100644
index 0000000..bfa6054
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/ServiceTrackerCustomizerAdapter.java
@@ -0,0 +1,33 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import org.osgi.framework.ServiceReference;
+import org.osgi.util.tracker.ServiceTrackerCustomizer;
+
+public abstract class ServiceTrackerCustomizerAdapter<S, T> implements ServiceTrackerCustomizer<S, T> {
+
+    @Override
+    public void modifiedService(ServiceReference<S> reference, T service) {
+        // nothing by default
+    }
+
+    @Override
+    public void removedService(ServiceReference<S> reference, T service) {
+        // nothing by default
+    }
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/StartupTimeCalculator.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/StartupTimeCalculator.java
new file mode 100644
index 0000000..3fbeb42
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/impl/StartupTimeCalculator.java
@@ -0,0 +1,136 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import java.lang.management.ManagementFactory;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import org.apache.felix.metrics.osgi.BundleStartDuration;
+import org.apache.felix.metrics.osgi.ServiceRestartCounter;
+import org.apache.felix.metrics.osgi.StartupMetrics;
+import org.apache.felix.metrics.osgi.StartupMetricsListener;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.InvalidSyntaxException;
+import org.osgi.framework.ServiceReference;
+import org.osgi.util.tracker.ServiceTracker;
+
+public class StartupTimeCalculator {
+
+    // delay activation until the system is marked as ready
+    // don't explicitly import the systemready class as this bundle must be started as early as possible in order
+    // to record bundle starup times
+    
+    static final String PROPERTY_READINESS_DELAY = "org.apache.felix.metrics.osgi.additionalReadinessDelayMillis";
+    private ServiceTracker<Object, Object> readyTracker;
+    private ServiceTracker<StartupMetricsListener, StartupMetricsListener> listenersTracker;
+    private BundleStartTimeCalculator bundleCalculator;
+    private ServiceRestartCountCalculator serviceCalculator;
+    private ScheduledExecutorService executor;
+    private Future<Void> future;
+    private Supplier<StartupMetrics> metricsSupplier;
+    private long additionalReadinessDelayMillis = TimeUnit.SECONDS.toMillis(5);
+
+    public StartupTimeCalculator(BundleContext ctx, BundleStartTimeCalculator bundleCalculator, ServiceRestartCountCalculator serviceCalculator) throws InvalidSyntaxException {
+        executor = Executors.newScheduledThreadPool(1);
+        try {
+            String readinessDelay = ctx.getProperty(PROPERTY_READINESS_DELAY);
+            additionalReadinessDelayMillis = Long.parseLong(readinessDelay);
+        } catch ( NumberFormatException e) {
+            Log.debug(getClass(), "Failed parsing readiness delay", e);
+        }
+        this.bundleCalculator = bundleCalculator;
+        this.serviceCalculator = serviceCalculator;
+        this.readyTracker = new ServiceTracker<>(ctx, 
+                ctx.createFilter("(|(" + Constants.OBJECTCLASS+"=org.apache.felix.systemready.SystemReady)(&(" + Constants.OBJECTCLASS+ "=org.apache.felix.hc.api.condition.Healthy)(tag=systemalive)))"),
+                new ServiceTrackerCustomizerAdapter<Object, Object>() {
+
+                    @Override
+                    public Object addingService(ServiceReference<Object> reference) {
+                        if ( future == null ) 
+                            future = calculate();
+                        return ctx.getService(reference);
+                    }
+                    
+                    @Override
+                    public void removedService(ServiceReference<Object> reference, Object service) {
+                        if ( future != null && !future.isDone() ) {
+                            boolean cancelled = future.cancel(false);
+                            if ( cancelled ) {
+                                metricsSupplier = null;
+                                future = null;
+                            }
+                        }
+                    }
+                });
+        this.readyTracker.open();
+        
+        this.listenersTracker = new ServiceTracker<>(ctx, StartupMetricsListener.class, new ServiceTrackerCustomizerAdapter<StartupMetricsListener, StartupMetricsListener>() {
+            @Override
+            public StartupMetricsListener addingService(ServiceReference<StartupMetricsListener> reference) {
+                StartupMetricsListener service = ctx.getService(reference);
+                // TODO - there is still a minor race condition, between the supplier being set and the registration of services
+                // which can cause the listener to receive the event twice
+                if ( metricsSupplier != null )
+                    service.onStartupComplete(metricsSupplier.get());
+                return service;
+            }
+        });
+        this.listenersTracker.open();
+    }
+
+    public void close() {
+        this.readyTracker.close();
+    }
+
+    private Future<Void> calculate() {
+
+        long currentMillis = Clock.systemUTC().millis();
+        
+        return executor.schedule(() -> {
+            long startupMillis = ManagementFactory.getRuntimeMXBean().getStartTime();
+            
+            Duration startupDuration = Duration.ofMillis(currentMillis - startupMillis);
+            Instant startupInstant = Instant.ofEpochMilli(startupMillis);
+            List<BundleStartDuration> bundleDurations = bundleCalculator.getBundleStartDurations();
+            List<ServiceRestartCounter> serviceRestarts = serviceCalculator.getServiceRestartCounters();
+            
+            metricsSupplier = () -> {
+                return StartupMetrics.Builder.withJvmStartup(startupInstant)
+                    .withStartupTime(startupDuration)
+                    .withBundleStartDurations(bundleDurations)
+                    .withServiceRestarts(serviceRestarts)
+                    .build();
+            };
+            
+            for ( StartupMetricsListener listener : listenersTracker.getServices(new StartupMetricsListener[0]) )
+                listener.onStartupComplete(metricsSupplier.get());
+            
+            return null;
+        }, additionalReadinessDelayMillis, TimeUnit.MILLISECONDS);
+        
+
+    }
+}
diff --git a/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/package-info.java b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/package-info.java
new file mode 100644
index 0000000..12d77b2
--- /dev/null
+++ b/metrics/osgi/collector/src/main/java/org/apache/felix/metrics/osgi/package-info.java
@@ -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.osgi.annotation.versioning.Version("1.0.0")
+package org.apache.felix.metrics.osgi;
diff --git a/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/AbstractIT.java b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/AbstractIT.java
new file mode 100644
index 0000000..f7fc59a
--- /dev/null
+++ b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/AbstractIT.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.felix.metrics.osgi.impl;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.ops4j.pax.exam.CoreOptions.bundle;
+import static org.ops4j.pax.exam.CoreOptions.composite;
+import static org.ops4j.pax.exam.CoreOptions.frameworkProperty;
+import static org.ops4j.pax.exam.CoreOptions.junitBundles;
+import static org.ops4j.pax.exam.CoreOptions.mavenBundle;
+import static org.ops4j.pax.exam.CoreOptions.options;
+
+import java.util.Arrays;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.inject.Inject;
+
+import org.apache.felix.metrics.osgi.StartupMetrics;
+import org.apache.felix.metrics.osgi.StartupMetricsListener;
+import org.apache.felix.metrics.osgi.impl.StartupTimeCalculator;
+import org.junit.Test;
+import org.ops4j.pax.exam.Configuration;
+import org.ops4j.pax.exam.Option;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceRegistration;
+
+public abstract class AbstractIT {
+
+    private static final String TESTED_BUNDLE_LOCATION = "reference:file:target/classes";
+
+    @Inject
+    protected BundleContext bc;
+
+    @Configuration
+    public Option[] config() {
+        return options(
+            // lower timeout, we don't have bounces
+            frameworkProperty(StartupTimeCalculator.PROPERTY_READINESS_DELAY).value("100"),
+            bundle(TESTED_BUNDLE_LOCATION),
+            junitBundles(),
+            mavenBundle("org.apache.felix", "org.apache.felix.scr", "2.1.16"),
+            mavenBundle("org.osgi", "org.osgi.util.promise", "1.1.1"),
+            mavenBundle("org.osgi", "org.osgi.util.function", "1.1.0"),
+            composite(specificOptions())
+        );
+    }
+    
+    protected abstract Option[] specificOptions();
+
+    @Test
+    public void registerListenerAfterSystemIsReady() throws InterruptedException {
+        runBasicTest(false);
+    }
+
+    @Test
+    public void registerListenerBeforeSystemIsReady() throws InterruptedException {
+        runBasicTest(true);
+    }
+
+    private void runBasicTest(boolean registerListenerFirst) throws InterruptedException {
+        
+        Set<String> expectedBundleNames = Arrays.stream(bc.getBundles())
+            .filter( b -> b.getBundleId() != Constants.SYSTEM_BUNDLE_ID ) // no framework bundle
+            .filter( b -> !b.getLocation().equals(TESTED_BUNDLE_LOCATION) ) // not the bundle under test
+            .map ( b -> b.getSymbolicName() )
+            .filter( bsn ->  ! bsn.startsWith("org.ops4j")  ) // no ops4j bundles
+            .filter( bsn ->  ! bsn.startsWith("PAXEXAM")  ) // no ops4j bundles
+            .filter( bsn -> ! bsn.contains("geronimo-atinject")) // injected early on by Pax-Exam
+            .collect(Collectors.toSet());
+        
+        WaitForResultsStartupMetricsListener listener = new WaitForResultsStartupMetricsListener();
+
+        // service that will be tracked as restarting
+        Runnable foo = () -> {};
+        Dictionary<String, Object> props = new Hashtable<>();
+        props.put(Constants.SERVICE_PID, "some.service.pid");
+        ServiceRegistration<Runnable> reg = bc.registerService(Runnable.class, foo, props);
+        reg.unregister();
+        reg = bc.registerService(Runnable.class, foo, props);
+        reg.unregister();
+
+        if ( registerListenerFirst ) {
+            markSystemReady();
+            bc.registerService(StartupMetricsListener.class, listener, null);
+        } else {
+            markSystemReady();
+            bc.registerService(StartupMetricsListener.class, listener, null);
+        }
+        
+        StartupMetrics metrics = listener.getMetrics();
+        
+        assertThat(metrics, notNullValue());
+        Set<String> trackedBundleNames = metrics.getBundleStartDurations().stream()
+                .map( bsd -> bsd.getSymbolicName())
+                .collect(Collectors.toSet());
+        
+        assertTrue("Tracked bundle names " + trackedBundleNames + " did not contain " + expectedBundleNames, 
+            trackedBundleNames.containsAll(expectedBundleNames));
+        
+        assertThat("Service restarts", metrics.getServiceRestarts().size(), equalTo(1));
+        assertThat("Restarted component service identifier", metrics.getServiceRestarts().get(0).getServiceIdentifier(), equalTo(Constants.SERVICE_PID+"="+props.get(Constants.SERVICE_PID)));
+    }
+
+    protected abstract void markSystemReady();
+}
diff --git a/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/BundleStartTimeCalculatorTest.java b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/BundleStartTimeCalculatorTest.java
new file mode 100644
index 0000000..70d18e7
--- /dev/null
+++ b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/BundleStartTimeCalculatorTest.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.felix.metrics.osgi.impl;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import java.time.Instant;
+
+import org.apache.felix.metrics.osgi.BundleStartDuration;
+import org.apache.felix.metrics.osgi.impl.BundleStartTimeCalculator;
+import org.hamcrest.CoreMatchers;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleEvent;
+
+public class BundleStartTimeCalculatorTest {
+
+    @Test
+    public void bundleStarted() {
+
+        Instant now = Instant.now();
+        
+        BundleStartTimeCalculator c = new BundleStartTimeCalculator(1l);
+        Bundle mockBundle = newMockBundle(5l, "foo");
+        c.bundleChanged(new BundleEvent(BundleEvent.STARTING, mockBundle));
+        c.bundleChanged(new BundleEvent(BundleEvent.STARTED, mockBundle));
+        
+        assertThat("Expected one entry for bundle durations",c.getBundleStartDurations().size(), CoreMatchers.equalTo(1));
+        BundleStartDuration duration = c.getBundleStartDurations().get(0);
+        assertThat("Bundle duration refers to wrong bundle symbolic name", duration.getSymbolicName(), CoreMatchers.equalTo("foo"));
+        
+        assertTrue("Bundle STARTING time (" + duration.getStartingAt() + " must be after test start time(" + now + ")", 
+                duration.getStartingAt().isAfter(now));
+        assertFalse("Bundle start duration (" + duration.getStartedAfter() + ") must not be negative",
+                duration.getStartedAfter().isNegative());
+    }
+    
+    private Bundle newMockBundle(long id, String symbolicName) {
+        
+        Bundle mockBundle = Mockito.mock(Bundle.class);
+        Mockito.when(mockBundle.getBundleId()).thenReturn(id);
+        Mockito.when(mockBundle.getSymbolicName()).thenReturn(symbolicName);
+        return mockBundle;
+    }
+}
diff --git a/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/HealthCheckSmokeIT.java b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/HealthCheckSmokeIT.java
new file mode 100644
index 0000000..f30fcdb
--- /dev/null
+++ b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/HealthCheckSmokeIT.java
@@ -0,0 +1,61 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import static org.ops4j.pax.exam.CoreOptions.frameworkProperty;
+import static org.ops4j.pax.exam.CoreOptions.mavenBundle;
+import static org.ops4j.pax.exam.CoreOptions.options;
+
+import java.util.Dictionary;
+import java.util.Hashtable;
+
+import javax.inject.Inject;
+
+import org.apache.felix.hc.api.condition.Healthy;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.framework.BundleContext;
+
+@RunWith(PaxExam.class)
+public class HealthCheckSmokeIT extends AbstractIT {
+
+    @Inject
+    private BundleContext bc;
+    
+    @Override
+    protected Option[] specificOptions() {
+        return options(
+            frameworkProperty("org.apache.felix.http.enable").value("false"),
+            mavenBundle("org.apache.felix", "org.apache.felix.healthcheck.api", "2.0.4"),
+            mavenBundle("org.apache.felix", "org.apache.felix.healthcheck.core", "2.0.8"),
+            mavenBundle("org.apache.felix", "org.apache.felix.healthcheck.generalchecks", "2.0.4"),
+            mavenBundle("org.apache.felix", "org.apache.felix.http.servlet-api", "1.1.2"),
+            mavenBundle("org.apache.felix", "org.apache.felix.http.jetty", "4.0.18"),
+            mavenBundle("org.apache.commons", "commons-lang3", "3.9"),
+            mavenBundle("org.apache.felix", "org.apache.felix.eventadmin", "1.5.0"),
+            mavenBundle("org.apache.felix", "org.apache.felix.rootcause", "0.1.0")
+        );
+    }
+    
+    @Override
+    protected void markSystemReady() {
+        Dictionary<String, Object> regProps = new Hashtable<>();
+        regProps.put("tag", "systemalive");
+        bc.registerService(Healthy.class, new Healthy() {}, regProps);
+    }
+}
diff --git a/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/ServiceRegistrationsTrackerTest.java b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/ServiceRegistrationsTrackerTest.java
new file mode 100644
index 0000000..e724829
--- /dev/null
+++ b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/ServiceRegistrationsTrackerTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.junit.Assert.assertThat;
+
+import org.apache.felix.metrics.osgi.impl.ServiceRestartCountCalculator.ServiceIdentifier;
+import org.apache.felix.metrics.osgi.impl.ServiceRestartCountCalculator.ServiceRegistrationsTracker;
+import org.junit.Before;
+import org.junit.Test;
+import org.osgi.framework.Constants;
+
+public class ServiceRegistrationsTrackerTest {
+
+    private ServiceRegistrationsTracker tracker;
+    
+    @Before
+    public void prepare() {
+        tracker = new ServiceRegistrationsTracker(new ServiceIdentifier(Constants.SERVICE_PID, "foo"));
+    }
+
+    @Test
+    public void singleRegister() {
+        tracker.registered();
+        assertThat(tracker.restartCount(), equalTo(0));
+    }
+
+    @Test
+    public void registerUnregister() {
+        tracker.registered();
+        tracker.unregistered();
+        assertThat(tracker.restartCount(), equalTo(0));
+    }
+
+    @Test
+    public void singleRestart() {
+        tracker.registered();
+        tracker.unregistered();
+        tracker.registered();
+        assertThat(tracker.restartCount(), equalTo(1));
+    }
+
+    @Test
+    public void singleRestartAndUnregister() {
+        tracker.registered();
+        tracker.unregistered();
+        tracker.registered();
+        tracker.unregistered();
+        assertThat(tracker.restartCount(), equalTo(1));
+    }
+    
+    @Test
+    public void twoRestarts() {
+        tracker.registered();
+        tracker.unregistered();
+        tracker.registered();
+        tracker.unregistered();
+        tracker.registered();
+        assertThat(tracker.restartCount(), equalTo(2));
+    }
+}
diff --git a/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/ServiceRestartCountCalculatorTest.java b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/ServiceRestartCountCalculatorTest.java
new file mode 100644
index 0000000..5b89ee8
--- /dev/null
+++ b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/ServiceRestartCountCalculatorTest.java
@@ -0,0 +1,192 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.junit.Assert.assertThat;
+
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.felix.metrics.osgi.impl.ServiceRestartCountCalculator;
+import org.hamcrest.CoreMatchers;
+import org.junit.Test;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceEvent;
+import org.osgi.framework.ServiceReference;
+import org.osgi.framework.ServiceRegistration;
+
+public class ServiceRestartCountCalculatorTest {
+
+    @Test
+    public void ignoredEventTypes() {
+        
+        ServiceRestartCountCalculator srcc = new ServiceRestartCountCalculator();
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.MODIFIED, new DummyServiceReference<>(new HashMap<>())));
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.MODIFIED_ENDMATCH, new DummyServiceReference<>(new HashMap<>())));
+        
+        assertThat(srcc.getRegistrations().size(), equalTo(0));
+    }
+
+    @Test
+    public void serviceWithServicePidProperty() {
+        
+        assertServiceWithPropertyIsTracked(Constants.SERVICE_PID);
+    }
+
+    @Test
+    public void serviceWithComponentNameProperty() {
+        
+        assertServiceWithPropertyIsTracked("component.name");
+    }
+
+    @Test
+    public void serviceWithJmxObjectNameProperty() {
+        
+        assertServiceWithPropertyIsTracked("jmx.objectname");
+    }
+
+    @Test
+    public void metricsGaugesAreTracked() {
+        HashMap<String, Object> props = new HashMap<>();
+        props.put(Constants.OBJECTCLASS, new String[] { "org.apache.sling.commons.metrics.Gauge" });
+        props.put("name", "commons.threads.tp.script-cache-thread-pool.Name");
+        DummyServiceReference<Object> dsr = new DummyServiceReference<>(props);
+        
+        ServiceRestartCountCalculator srcc = new ServiceRestartCountCalculator();
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.REGISTERED, dsr));
+        
+        assertThat(srcc.getRegistrations().size(), equalTo(1));
+    }
+
+    @Test
+    public void unknownServiceIsNotTracked() {
+        HashMap<String, Object> props = new HashMap<>();
+        props.put(Constants.OBJECTCLASS, new String[] { "foo" });
+        DummyServiceReference<Object> dsr = new DummyServiceReference<>(props);
+        
+        ServiceRestartCountCalculator srcc = new ServiceRestartCountCalculator();
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.REGISTERED, dsr));
+        
+        assertThat(srcc.getRegistrations().size(), equalTo(0));
+        assertThat(srcc.getUnidentifiedRegistrationsByClassName().size(), equalTo(0));
+    }
+    
+    @Test
+    public void unknownServiceUnregistrationsAreTracked() {
+        HashMap<String, Object> props = new HashMap<>();
+        props.put(Constants.OBJECTCLASS, new String[] { "foo", "bar" });
+        DummyServiceReference<Object> sr1 = new DummyServiceReference<>(props);
+        
+        HashMap<String, Object> props2 = new HashMap<>();
+        props2.put(Constants.OBJECTCLASS, new String[] { "foo"} );
+        DummyServiceReference<Object> sr2 = new DummyServiceReference<>(props2);
+        
+        ServiceRestartCountCalculator srcc = new ServiceRestartCountCalculator();
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.REGISTERED, sr1));
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.UNREGISTERING, sr1));
+        
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.REGISTERED, sr2));
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.UNREGISTERING, sr2));
+        
+        assertThat(srcc.getRegistrations().size(), equalTo(0));
+        Map<String, Integer> unidentifiedRegistrations = srcc.getUnidentifiedRegistrationsByClassName();
+        assertThat(unidentifiedRegistrations.size(), equalTo(2));
+        assertThat(unidentifiedRegistrations.get("foo"), equalTo(2));
+        assertThat(unidentifiedRegistrations.get("bar"), equalTo(1));
+    }
+    
+    private void assertServiceWithPropertyIsTracked(String propertyName) {
+        
+        HashMap<String, Object> props = new HashMap<>();
+        props.put(propertyName, new String[] { "foo.bar" });
+        DummyServiceReference<Object> dsr = new DummyServiceReference<>(props);
+        
+        ServiceRestartCountCalculator srcc = new ServiceRestartCountCalculator();
+        srcc.serviceChanged(new ServiceEvent(ServiceEvent.REGISTERED, dsr));
+        
+        assertThat(srcc.getRegistrations().size(), CoreMatchers.equalTo(1));
+    }
+    
+    static class DummyServiceRegistration<S> implements ServiceRegistration<S> {
+        
+        private final DummyServiceReference<S> sr;
+
+        public DummyServiceRegistration(Map<String, Object> props) {
+            this.sr = new DummyServiceReference<>(props);
+        }
+
+        @Override
+        public ServiceReference<S> getReference() {
+            return sr;
+        }
+
+        @Override
+        public void setProperties(Dictionary<String, ?> properties) {
+            throw new UnsupportedOperationException();
+            
+        }
+
+        @Override
+        public void unregister() {
+            throw new UnsupportedOperationException();            
+        }
+    }
+    
+    static class DummyServiceReference<S> implements ServiceReference<S> {
+        
+        private final Map<String, Object> props;
+
+        public DummyServiceReference(Map<String, Object> props) {
+            this.props = props;
+        }
+
+        @Override
+        public Object getProperty(String key) {
+            return props.get(key);
+        }
+
+        @Override
+        public String[] getPropertyKeys() {
+            return props.keySet().toArray(new String[0]);
+        }
+
+        @Override
+        public Bundle getBundle() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Bundle[] getUsingBundles() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean isAssignableTo(Bundle bundle, String className) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int compareTo(Object reference) {
+            throw new UnsupportedOperationException();
+        }
+        
+    }
+
+}
diff --git a/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/SystemReadySmokeIT.java b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/SystemReadySmokeIT.java
new file mode 100644
index 0000000..2d9cfc2
--- /dev/null
+++ b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/SystemReadySmokeIT.java
@@ -0,0 +1,47 @@
+/*
+ * 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.felix.metrics.osgi.impl;
+
+import static org.ops4j.pax.exam.CoreOptions.mavenBundle;
+import static org.ops4j.pax.exam.CoreOptions.options;
+
+import javax.inject.Inject;
+
+import org.apache.felix.systemready.SystemReady;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.framework.BundleContext;
+
+@RunWith(PaxExam.class)
+public class SystemReadySmokeIT extends AbstractIT {
+
+    @Inject
+    protected BundleContext bc;
+
+    @Override
+    protected Option[] specificOptions() {
+        return options(
+            mavenBundle("org.apache.felix", "org.apache.felix.systemready", "0.4.2"),
+            mavenBundle("org.apache.felix", "org.apache.felix.rootcause", "0.1.0")
+        );
+    }
+    protected void markSystemReady() {
+        bc.registerService(SystemReady.class, new SystemReady() {}, null);
+    }
+
+}
diff --git a/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/WaitForResultsStartupMetricsListener.java b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/WaitForResultsStartupMetricsListener.java
new file mode 100644
index 0000000..42bbba1
--- /dev/null
+++ b/metrics/osgi/collector/src/test/java/org/apache/felix/metrics/osgi/impl/WaitForResultsStartupMetricsListener.java
@@ -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.
+ */
+package org.apache.felix.metrics.osgi.impl;
+
+import java.util.concurrent.CountDownLatch;
+
+import org.apache.felix.metrics.osgi.StartupMetrics;
+import org.apache.felix.metrics.osgi.StartupMetricsListener;
+
+class WaitForResultsStartupMetricsListener implements StartupMetricsListener {
+    
+    private final CountDownLatch latch = new CountDownLatch(1);
+    private StartupMetrics metrics;
+
+    @Override
+    public void onStartupComplete(StartupMetrics metrics) {
+        this.metrics = metrics;
+        latch.countDown();
+    }
+
+    public StartupMetrics getMetrics() {
+        try {
+            latch.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+        return metrics;
+    }
+    
+}
\ No newline at end of file
diff --git a/metrics/osgi/consumers/bnd.bnd b/metrics/osgi/consumers/bnd.bnd
new file mode 100644
index 0000000..0be6687
--- /dev/null
+++ b/metrics/osgi/consumers/bnd.bnd
@@ -0,0 +1 @@
+Conditional-Package: org.apache.felix.utils.json
\ No newline at end of file
diff --git a/metrics/osgi/consumers/pom.xml b/metrics/osgi/consumers/pom.xml
new file mode 100644
index 0000000..5b02583
--- /dev/null
+++ b/metrics/osgi/consumers/pom.xml
@@ -0,0 +1,104 @@
+<?xml version="1.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. -->
+<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/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.sling</groupId>
+        <artifactId>sling-bundle-parent</artifactId>
+        <version>38</version>
+        <relativePath />
+    </parent>
+
+    <groupId>org.apache.felix</groupId>
+    <artifactId>org.apache.felix.metrics.osgi.consumers</artifactId>
+    <version>0.1.0-SNAPSHOT</version>
+
+    <name>Apache Felix OSGi Metrics Consumers</name>
+    <description> 
+        Provides various out-of-the-box consumers for OSGi framework metrics.
+    </description>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>biz.aQute.bnd</groupId>
+                <artifactId>bnd-baseline-maven-plugin</artifactId>
+                <configuration>
+                    <failOnMissing>false</failOnMissing>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.annotation.versioning</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.annotation.bundle</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.component.annotations</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.metatype.annotations</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>osgi.core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+            <version>3.2.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.metrics.osgi.collector</artifactId>
+            <version>0.1.0-SNAPSHOT</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+             <artifactId>org.apache.felix.utils</artifactId>
+             <version>1.11.4</version>
+             <scope>provided</scope>
+        </dependency>
+        
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>3.3.3</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+</project>
diff --git a/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/dropwizard/DropwizardMetricsListener.java b/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/dropwizard/DropwizardMetricsListener.java
new file mode 100644
index 0000000..7333576
--- /dev/null
+++ b/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/dropwizard/DropwizardMetricsListener.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.felix.metrics.osgi.consumers.impl.dropwizard;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.felix.metrics.osgi.StartupMetrics;
+import org.apache.felix.metrics.osgi.StartupMetricsListener;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
+
+@Component
+@Designate(ocd = DropwizardMetricsListener.Config.class)
+public class DropwizardMetricsListener implements StartupMetricsListener {
+
+    @ObjectClassDefinition(name = "Apache Felix Dropwizard Startup Metrics Listener")
+    public @interface Config {
+        @AttributeDefinition(name = "Service Restart Threshold", description="Minimum number of service restarts during startup needed to create a metric for the service")
+        int serviceRestartThreshold() default 3;
+        @AttributeDefinition(name = "Slow Bundle Startup Threshold", description="Minimum bundle startup duration in milliseconds needed to create a metric for the bundle")
+        long slowBundleThresholdMillis() default 200;
+    }
+
+    private static final String APPLICATION_STARTUP_GAUGE_NAME = "osgi.application_startup_time_millis";
+    private static final String BUNDLE_STARTUP_GAUGE_NAME_PREFIX = "osgi.slow_bundle_startup_time_millis.";
+    private static final String SERVICE_RESTART_GAUGE_NAME_PREFIX = "osgi.excessive_service_restarts_count.";
+    
+    @Reference
+    private MetricRegistry registry;
+    
+    private int serviceRestartThreshold;
+    private long slowBundleThresholdMillis;
+    private List<String> registeredMetricNames = new ArrayList<>();
+    
+    @Activate
+    protected void activate(Config cfg) {
+        this.serviceRestartThreshold = cfg.serviceRestartThreshold();
+        this.slowBundleThresholdMillis = cfg.slowBundleThresholdMillis();
+    }
+
+    @Deactivate
+    protected void deactivate() {
+        registeredMetricNames.forEach( m -> registry.remove(m) );
+    }
+    
+    @Override
+    public void onStartupComplete(StartupMetrics event) {
+        register(APPLICATION_STARTUP_GAUGE_NAME, (Gauge<Long>) () -> event.getStartupTime().toMillis() );
+        event.getBundleStartDurations().stream()
+            .filter( bsd -> bsd.getStartedAfter().toMillis() >= slowBundleThresholdMillis )
+            .forEach( bsd -> register(BUNDLE_STARTUP_GAUGE_NAME_PREFIX + bsd.getSymbolicName(), (Gauge<Long>) () -> bsd.getStartedAfter().toMillis()));
+        event.getServiceRestarts().stream()
+            .filter( src -> src.getServiceRestarts() >= serviceRestartThreshold )
+            .forEach( src -> register(SERVICE_RESTART_GAUGE_NAME_PREFIX + src.getServiceIdentifier(), (Gauge<Integer>) src::getServiceRestarts) );
+    }
+    
+    private void register(String name, Metric metric) {
+        registry.register(name, metric);
+        registeredMetricNames.add(name);
+    }
+}
diff --git a/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/json/JsonWritingMetricsListener.java b/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/json/JsonWritingMetricsListener.java
new file mode 100644
index 0000000..4174e39
--- /dev/null
+++ b/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/json/JsonWritingMetricsListener.java
@@ -0,0 +1,95 @@
+/*
+ * 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.felix.metrics.osgi.consumers.impl.json;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+import org.apache.felix.metrics.osgi.BundleStartDuration;
+import org.apache.felix.metrics.osgi.ServiceRestartCounter;
+import org.apache.felix.metrics.osgi.StartupMetrics;
+import org.apache.felix.metrics.osgi.StartupMetricsListener;
+import org.apache.felix.utils.json.JSONWriter;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Component
+public class JsonWritingMetricsListener implements StartupMetricsListener {
+
+    private final Logger logger = LoggerFactory.getLogger(getClass());
+    
+    private BundleContext ctx;
+
+    @Activate
+    protected void activate(BundleContext ctx) {
+        this.ctx = ctx;
+    }
+
+    @Override
+    public void onStartupComplete(StartupMetrics metrics) {
+
+        File metricsFile = ctx.getDataFile("startup-metrics-" + System.currentTimeMillis() + ".json");
+        if ( metricsFile == null ) {
+            logger.warn("Unable to get data file in the bundle area, startup metrics will not be written");
+            return;
+        }
+        
+        try {
+            try ( FileWriter fw = new FileWriter(metricsFile)) {
+                JSONWriter w = new JSONWriter(fw);
+                w.object();
+                // application metrics
+                w.key("application");
+                w.object();
+                w.key("startTimeMillis").value(metrics.getJvmStartup().toEpochMilli());
+                w.key("startDurationMillis").value(metrics.getStartupTime().toMillis());
+                w.endObject();
+                
+                // bundle metrics
+                w.key("bundles");
+                w.array();
+                for ( BundleStartDuration bsd : metrics.getBundleStartDurations() ) {
+                    w.object();
+                    w.key("symbolicName").value(bsd.getSymbolicName());
+                    w.key("startTimeMillis").value(bsd.getStartingAt().toEpochMilli());
+                    w.key("startDurationMillis").value(bsd.getStartedAfter().toMillis());
+                    w.endObject();
+                }
+                w.endArray();
+                
+                // service metrics
+                w.key("services");
+                w.array();
+                for ( ServiceRestartCounter src : metrics.getServiceRestarts() ) {
+                    w.object();
+                    w.key("identifier").value(src.getServiceIdentifier());
+                    w.key("restarts").value(src.getServiceRestarts());
+                    w.endObject();
+                }
+                w.endArray();
+                
+                w.endObject();
+            }
+        } catch (IOException e) {
+            logger.warn("Failed wrting startup metrics", e);
+        }
+    }
+}
diff --git a/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/log/LoggingMetricsListener.java b/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/log/LoggingMetricsListener.java
new file mode 100644
index 0000000..e96619e
--- /dev/null
+++ b/metrics/osgi/consumers/src/main/java/org/apache/felix/metrics/osgi/consumers/impl/log/LoggingMetricsListener.java
@@ -0,0 +1,92 @@
+/*
+ * 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.felix.metrics.osgi.consumers.impl.log;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.felix.metrics.osgi.BundleStartDuration;
+import org.apache.felix.metrics.osgi.ServiceRestartCounter;
+import org.apache.felix.metrics.osgi.StartupMetrics;
+import org.apache.felix.metrics.osgi.StartupMetricsListener;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Component
+@Designate(ocd = LoggingMetricsListener.Config.class)
+public class LoggingMetricsListener implements StartupMetricsListener {
+    
+    @ObjectClassDefinition(name = "Apache Felix Logging Startup Metrics Listener")
+    public @interface Config {
+        
+        @AttributeDefinition(name = "Service Restart Threshold", description="Minimum number of service restarts during startup needed log the number of service restarts")
+        int serviceRestartThreshold() default 3;
+        @AttributeDefinition(name = "Slow Bundle Startup Threshold", description="Minimum bundle startup duration in milliseconds needed to log the bundle startup time")
+        long slowBundleThresholdMillis() default 200;
+    }
+
+    private int serviceRestartThreshold;
+    private long slowBundleThresholdMillis;
+    
+    @Activate
+    protected void activate(Config cfg) {
+        this.serviceRestartThreshold = cfg.serviceRestartThreshold();
+        this.slowBundleThresholdMillis = cfg.slowBundleThresholdMillis();
+    }
+    
+    @Override
+    public void onStartupComplete(StartupMetrics event) {
+        Logger log = LoggerFactory.getLogger(getClass());
+        log.info("Application startup completed in {}", event.getStartupTime());
+
+        List<BundleStartDuration> slowStartBundles = event.getBundleStartDurations().stream()
+                .filter( bsd -> bsd.getStartedAfter().toMillis() >= slowBundleThresholdMillis )
+                .collect(Collectors.toList());
+        
+        if ( !slowStartBundles.isEmpty() && log.isInfoEnabled() ) {
+            StringBuilder logEntry = new StringBuilder();
+            logEntry.append("The following bundles started in more than ")
+                .append(slowBundleThresholdMillis)
+                .append(" milliseconds: \n");
+            slowStartBundles
+                .forEach( ssb -> logEntry.append("- ").append(ssb.getSymbolicName()).append(" : ").append(ssb.getStartedAfter()).append('\n'));
+            
+            log.info(logEntry.toString());
+        }
+        
+        List<ServiceRestartCounter> oftenRestartedServices = event.getServiceRestarts().stream()
+                .filter( src -> src.getServiceRestarts() >= serviceRestartThreshold )
+                .collect(Collectors.toList());
+        
+        if ( !oftenRestartedServices.isEmpty() && log.isInfoEnabled() ) {
+            StringBuilder logEntry = new StringBuilder();
+            logEntry.append("The following services have restarted more than ")
+                .append(serviceRestartThreshold)
+                .append(" times during startup :\n");
+            oftenRestartedServices
+                .forEach(ors -> logEntry.append("- ").append(ors.getServiceIdentifier()).append(" : ").append(ors.getServiceRestarts()).append(" restarts\n"));
+            
+            log.info(logEntry.toString());
+        }
+    }
+
+}
diff --git a/metrics/osgi/consumers/src/test/java/org/apache/felix/metrics/osgi/consumers/impl/json/JsonWritingMetricsListenerTest.java b/metrics/osgi/consumers/src/test/java/org/apache/felix/metrics/osgi/consumers/impl/json/JsonWritingMetricsListenerTest.java
new file mode 100644
index 0000000..bd877f4
--- /dev/null
+++ b/metrics/osgi/consumers/src/test/java/org/apache/felix/metrics/osgi/consumers/impl/json/JsonWritingMetricsListenerTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.felix.metrics.osgi.consumers.impl.json;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.hasItem;
+import static org.junit.Assert.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+
+import org.apache.felix.metrics.osgi.BundleStartDuration;
+import org.apache.felix.metrics.osgi.ServiceRestartCounter;
+import org.apache.felix.metrics.osgi.StartupMetrics;
+import org.apache.felix.metrics.osgi.consumers.impl.json.JsonWritingMetricsListener;
+import org.apache.felix.utils.json.JSONParser;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+import org.osgi.framework.BundleContext;
+
+public class JsonWritingMetricsListenerTest {
+
+    @Rule
+    public TemporaryFolder tmp = new TemporaryFolder();
+    
+    @Test
+    public void metricsArePersisted() throws IOException {
+        // bridge the mock bundle context with the temporary folder
+        BundleContext mockBundleContext = mock(BundleContext.class);
+        when(mockBundleContext.getDataFile(Mockito.anyString())).thenAnswer( i -> tmp.newFile(i.getArgument(0, String.class)));
+        
+        JsonWritingMetricsListener listener = new JsonWritingMetricsListener();
+        listener.activate(mockBundleContext);
+        
+        StartupMetrics metrics = StartupMetrics.Builder
+                .withJvmStartup(Instant.now())
+                .withStartupTime(Duration.ofMillis(50))
+                .withBundleStartDurations(Arrays.asList(new BundleStartDuration("foo", Instant.now(), Duration.ofMillis(5))))
+                .withServiceRestarts(Arrays.asList(new ServiceRestartCounter("some.service", 1)))
+                .build();
+        
+        listener.onStartupComplete(metrics);
+        
+        File[] files = tmp.getRoot().listFiles();
+        
+        assertThat("Bundle data area should hold one file", files.length, equalTo(1));
+        
+        File metricsFile = files[0];
+        try ( FileInputStream fis = new FileInputStream(metricsFile)) {
+            JSONParser p = new JSONParser(fis);
+            assertThat(p.getParsed().keySet(), hasItem("application"));
+            assertThat(p.getParsed().keySet(), hasItem("bundles"));
+            assertThat(p.getParsed().keySet(), hasItem("services"));
+        }
+    }
+}
diff --git a/metrics/osgi/pom.xml b/metrics/osgi/pom.xml
new file mode 100644
index 0000000..0e6ac13
--- /dev/null
+++ b/metrics/osgi/pom.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+--><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/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache</groupId>
+        <artifactId>apache</artifactId>
+        <version>18</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>org.apache.felix</groupId>
+    <artifactId>felix-metrics-osgi-builder</artifactId>
+    <packaging>pom</packaging>
+    <version>1</version>
+
+    <name>Apache Felix OSGi Metrics (Builder)</name>
+
+    <modules>
+          <module>collector</module>
+          <module>consumers</module>
+    </modules>
+</project>
+