SLING-7055 - RRD4J metrics reporter

Initial implementation

Submitted-By: Marcel Reutegger

git-svn-id: https://svn.apache.org/repos/asf/sling/trunk@1805386 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..542068f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,16 @@
+# Apache Sling RRD4J metrics reporter
+
+This is a bundle that stores metrics on the local filesystem using
+[RRD4J](https://github.com/rrd4j/rrd4j).
+
+Build this bundle with Maven:
+
+    mvn clean install
+
+The reporter will not store metrics by default. You need to configure it and
+tell the reporter what metrics to store.
+
+Go to the Apache Felix Web Console and configure 'Apache Sling Metrics reporter
+writing to RRD4J'. The reporter will start storing metrics once data sources
+have been added and the configuration is saved. Please note, the metrics file
+is recreated/cleared whenever the configuration is changed.
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..4ef5901
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,105 @@
+<?xml version="1.0" encoding="ISO-8859-1"?>
+<!--
+  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</artifactId>
+        <version>31</version>
+    </parent>
+
+    <artifactId>org.apache.sling.commons.metrics-rrd4j</artifactId>
+    <packaging>bundle</packaging>
+    <version>0.0.1-SNAPSHOT</version>
+
+    <name>Apache Sling RRD4J metrics reporter</name>
+    <description>
+       Stores Metrics to the local filesystem using RRD4J.
+    </description>
+
+    <properties>
+        <sling.java.version>8</sling.java.version>
+    </properties>
+
+    <scm>
+        <connection>scm:svn:http://svn.apache.org/repos/asf/sling/trunk/bundles/commons/metrics-rrd4j</connection>
+        <developerConnection>scm:svn:https://svn.apache.org/repos/asf/sling/trunk/bundles/commons/metrics-rrd4j</developerConnection>
+        <url>http://svn.apache.org/viewvc/sling/trunk/bundles/commons/metrics-rrd4j</url>
+    </scm>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.felix</groupId>
+                <artifactId>maven-bundle-plugin</artifactId>
+                <extensions>true</extensions>
+                <configuration>
+                    <instructions>
+                        <Import-Package>
+                            com.mongodb;resolution:=optional,
+                            com.sleepycat.je;resolution:=optional,
+                            sun.misc;resolution:=optional,
+                            sun.nio.ch;resolution:=optional,
+                            *
+                        </Import-Package>
+                        <Embed-Dependency>rrd4j</Embed-Dependency>
+                    </instructions>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+    <dependencies>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+            <version>3.1.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.rrd4j</groupId>
+            <artifactId>rrd4j</artifactId>
+            <version>3.1</version>
+            <scope>provided</scope>
+        </dependency>
+        <!-- test dependencies -->
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <version>1.1.7</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sling</groupId>
+            <artifactId>org.apache.sling.testing.osgi-mock</artifactId>
+            <version>2.3.2</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/CodahaleMetricsReporter.java b/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/CodahaleMetricsReporter.java
new file mode 100644
index 0000000..97cf78a
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/CodahaleMetricsReporter.java
@@ -0,0 +1,170 @@
+/*
+ * 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.sling.commons.metrics.rrd4j.impl;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ConfigurationPolicy;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.ReferenceCardinality;
+import org.osgi.service.component.annotations.ReferencePolicy;
+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;
+
+import java.io.File;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import static org.apache.sling.commons.metrics.rrd4j.impl.RRD4JReporter.DEFAULT_STEP;
+
+@Component(
+        immediate = true,
+        configurationPolicy = ConfigurationPolicy.REQUIRE
+)
+@Designate(ocd = CodahaleMetricsReporter.Configuration.class)
+public class CodahaleMetricsReporter {
+
+    private static final Logger LOG = LoggerFactory.getLogger(CodahaleMetricsReporter.class);
+
+    private ScheduledReporter reporter;
+
+    private Map<String, CopyMetricRegistryListener> listeners = new ConcurrentHashMap<>();
+
+    @ObjectClassDefinition(name = "Apache Sling Metrics reporter writing to RRD4J",
+            description = "For syntax details on RRD data-source and round " +
+                    "robin archive definitions see " +
+                    "https://oss.oetiker.ch/rrdtool/doc/rrdcreate.en.html and " +
+                    "https://github.com/rrd4j/rrd4j/wiki/Tutorial. Changing " +
+                    "any attribute in this configuration will replace an " +
+                    "existing RRD file with a empty one!")
+    public @interface Configuration {
+
+        @AttributeDefinition(
+                name = "Data sources",
+                description = "RRDTool data source definitions " +
+                        "(e.g. 'DS:oak_SESSION_LOGIN_COUNTER:COUNTER:300:0:U'). " +
+                        "Replace colon characters in the metric name with an " +
+                        "underscore!"
+        )
+        String[] datasources() default {};
+
+        @AttributeDefinition(
+                name = "Step",
+                description = "The base interval in seconds with which data " +
+                        "will be fed into the RRD"
+        )
+        int step() default DEFAULT_STEP;
+
+        @AttributeDefinition(
+                name = "Archives",
+                description = "RRDTool round robin archive definitions. The " +
+                        "default configuration defines four archives based " +
+                        "on a default step of five seconds: " +
+                        "1) per minute averages for six hours, " +
+                        "2) per five minute averages 48 hours, " +
+                        "3) per hour averages for four weeks, " +
+                        "4) per day averages for one year."
+        )
+        String[] archives() default {
+            "RRA:AVERAGE:0.5:12:360", "RRA:AVERAGE:0.5:60:576", "RRA:AVERAGE:0.5:720:336", "RRA:AVERAGE:0.5:17280:365"
+        };
+
+        @AttributeDefinition(
+                name = "Path",
+                description = "Path of the RRD file where metrics are stored. " +
+                        "If the path is relative, it is resolved relative to " +
+                        "the value of the framework property 'sling.home' when " +
+                        "available, otherwise relative to the current working " +
+                        "directory."
+        )
+        String path() default "metrics/metrics.rrd";
+    }
+
+    private MetricRegistry metricRegistry = new MetricRegistry();
+
+    @Activate
+    void activate(BundleContext context, Configuration config) throws Exception {
+        LOG.info("Starting RRD4J Metrics reporter");
+        File path = new File(config.path());
+        if (!path.isAbsolute()) {
+            String home = context.getProperty("sling.home");
+            if (home != null) {
+                path = new File(home, path.getPath());
+            }
+        }
+
+        reporter = RRD4JReporter.forRegistry(metricRegistry)
+                .convertRatesTo(TimeUnit.SECONDS)
+                .convertDurationsTo(TimeUnit.MICROSECONDS)
+                .withPath(path)
+                .withDatasources(config.datasources())
+                .withArchives(config.archives())
+                .withStep(config.step())
+                .build();
+        reporter.start(config.step(), TimeUnit.SECONDS);
+        LOG.info("Started RRD4J Metrics reporter. Writing to " + path);
+    }
+
+    @Deactivate
+    void deactivate() {
+        LOG.info("Stopping RRD4J Metrics reporter");
+        reporter.stop();
+        reporter = null;
+        LOG.info("Stopped RRD4J Metrics reporter");
+    }
+
+    @Reference(
+            service = MetricRegistry.class,
+            cardinality = ReferenceCardinality.MULTIPLE,
+            policy = ReferencePolicy.DYNAMIC)
+    synchronized void addMetricRegistry(MetricRegistry metricRegistry,
+                                        Map<String, Object> properties) {
+        String name = (String) properties.get("name");
+        if (name == null) {
+            name = metricRegistry.toString();
+        }
+        CopyMetricRegistryListener listener = new CopyMetricRegistryListener(this.metricRegistry, name);
+        listener.start(metricRegistry);
+        this.listeners.put(name, listener);
+        LOG.info("Bound Metrics Registry {} ",name);
+    }
+
+    synchronized void removeMetricRegistry(MetricRegistry metricRegistry,
+                                           Map<String, Object> properties) {
+        String name = (String) properties.get("name");
+        if (name == null) {
+            name = metricRegistry.toString();
+        }
+        CopyMetricRegistryListener metricRegistryListener = listeners.get(name);
+        if ( metricRegistryListener != null) {
+            metricRegistryListener.stop(metricRegistry);
+            this.listeners.remove(name);
+        }
+        LOG.info("Unbound Metrics Registry {} ",name);
+    }
+}
diff --git a/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/CopyMetricRegistryListener.java b/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/CopyMetricRegistryListener.java
new file mode 100644
index 0000000..fbb20aa
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/CopyMetricRegistryListener.java
@@ -0,0 +1,113 @@
+/*
+ * 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.sling.commons.metrics.rrd4j.impl;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.MetricRegistryListener;
+import com.codahale.metrics.Timer;
+
+class CopyMetricRegistryListener implements MetricRegistryListener {
+
+    private final MetricRegistry parent;
+    private final String name;
+
+    CopyMetricRegistryListener(MetricRegistry parent, String name) {
+        this.parent = parent;
+        this.name = name;
+    }
+
+    void start(MetricRegistry metricRegistry) {
+        metricRegistry.addListener(this);
+    }
+
+    void stop(MetricRegistry metricRegistry) {
+        metricRegistry.removeListener(this);
+        for(String name : metricRegistry.getMetrics().keySet()) {
+            removeMetric(name);
+        }
+    }
+
+    private void addMetric(String metricName, Metric m) {
+        parent.register(getMetricName(metricName), m);
+    }
+
+    private void removeMetric(String metricName) {
+        parent.remove(getMetricName(metricName));
+    }
+
+    private String getMetricName(String metricName) {
+        return name + "_" + metricName;
+    }
+
+    @Override
+    public void onGaugeAdded(String s, Gauge<?> gauge) {
+        addMetric(s, gauge);
+    }
+
+    @Override
+    public void onGaugeRemoved(String s) {
+        removeMetric(s);
+    }
+
+    @Override
+    public void onCounterAdded(String s, Counter counter) {
+        addMetric(s, counter);
+    }
+
+    @Override
+    public void onCounterRemoved(String s) {
+        removeMetric(s);
+    }
+
+    @Override
+    public void onHistogramAdded(String s, Histogram histogram) {
+        addMetric(s, histogram);
+    }
+
+    @Override
+    public void onHistogramRemoved(String s) {
+        removeMetric(s);
+    }
+
+    @Override
+    public void onMeterAdded(String s, Meter meter) {
+        addMetric(s, meter);
+    }
+
+    @Override
+    public void onMeterRemoved(String s) {
+        removeMetric(s);
+    }
+
+    @Override
+    public void onTimerAdded(String s, Timer timer) {
+        addMetric(s, timer);
+    }
+
+    @Override
+    public void onTimerRemoved(String s) {
+        removeMetric(s);
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/RRD4JReporter.java b/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/RRD4JReporter.java
new file mode 100644
index 0000000..8eaacfa
--- /dev/null
+++ b/src/main/java/org/apache/sling/commons/metrics/rrd4j/impl/RRD4JReporter.java
@@ -0,0 +1,289 @@
+/*
+ * 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.sling.commons.metrics.rrd4j.impl;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+import com.codahale.metrics.Timer;
+
+import org.rrd4j.core.RrdDb;
+import org.rrd4j.core.RrdDef;
+import org.rrd4j.core.Sample;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.SortedMap;
+import java.util.concurrent.TimeUnit;
+
+import static java.lang.String.join;
+
+class RRD4JReporter extends ScheduledReporter {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(RRD4JReporter.class);
+    static final int DEFAULT_STEP = 5;
+
+    private final Map<String, Integer> dictionary = new HashMap<>();
+    private final RrdDb rrdDB;
+
+    static Builder forRegistry(MetricRegistry metricRegistry) {
+        return new Builder(metricRegistry);
+    }
+
+    static class Builder {
+        private MetricRegistry metricRegistry;
+        private TimeUnit ratesUnit;
+        private TimeUnit durationUnit;
+        private File path = new File(".");
+        private final List<String> indexedDS = new ArrayList<>();
+        private final Map<String, Integer> dictionary = new HashMap<>();
+        private final List<String> archives = new ArrayList<>();
+        private int step = DEFAULT_STEP;
+
+        Builder(MetricRegistry metricRegistry ) {
+            this.metricRegistry = metricRegistry;
+        }
+
+        Builder withPath(File path) {
+            this.path = path;
+            return this;
+        }
+
+        Builder withDatasources(String[] datasources) {
+            this.indexedDS.clear();
+            this.dictionary.clear();
+
+            int i = 0;
+            for (String ds : datasources) {
+                String[] tokens = ds.split(":");
+                if (tokens.length != 6) {
+                    throw new IllegalArgumentException("Invalid data source definition: " + ds);
+                }
+                dictionary.put(normalize(tokens[1]), i);
+                tokens[1] = String.valueOf(i);
+                this.indexedDS.add(checkDataSource(join(":", tokens)));
+                i++;
+            }
+            return this;
+        }
+
+        Builder withArchives(String[] archives) {
+            this.archives.clear();
+            this.archives.addAll(Arrays.asList(archives));
+            return this;
+        }
+
+        Builder withStep(int step) {
+            this.step = step;
+            return this;
+        }
+
+        Builder convertRatesTo(TimeUnit ratesUnit) {
+            this.ratesUnit = ratesUnit;
+            return this;
+        }
+
+        Builder convertDurationsTo(TimeUnit durationUnit) {
+            this.durationUnit = durationUnit;
+            return this;
+        }
+
+        ScheduledReporter build() throws IOException {
+            return new RRD4JReporter(metricRegistry, "RRD4JReporter",
+                    MetricFilter.ALL, ratesUnit, durationUnit, dictionary, createDef());
+        }
+
+        private String checkDataSource(String ds)
+                throws IllegalArgumentException {
+            new RrdDef("path").addDatasource(ds);
+            return ds;
+        }
+
+        private RrdDef createDef() {
+            RrdDef def = new RrdDef(path.getPath());
+            def.setStep(step);
+            for (String ds : indexedDS) {
+                def.addDatasource(ds);
+            }
+            for (String rra : archives) {
+                def.addArchive(rra);
+            }
+            return def;
+        }
+    }
+
+    RRD4JReporter(MetricRegistry registry,
+                  String name,
+                  MetricFilter filter,
+                  TimeUnit rateUnit,
+                  TimeUnit durationUnit,
+                  Map<String, Integer> dictionary,
+                  RrdDef rrdDef) throws IOException {
+        super(registry, name, filter, rateUnit, durationUnit);
+        this.dictionary.putAll(dictionary);
+        this.rrdDB = createDB(rrdDef);
+        storeDictionary(rrdDef.getPath() + ".properties");
+    }
+
+    @Override
+    public void close() {
+        try {
+            rrdDB.close();
+        } catch (IOException e) {
+            LOGGER.warn("Closing RRD failed", e);
+        }
+        super.close();
+    }
+
+    @Override
+    public void report(SortedMap<String, Gauge> gauges,
+                       SortedMap<String, Counter> counters,
+                       SortedMap<String, Histogram> histograms,
+                       SortedMap<String, Meter> meters,
+                       SortedMap<String, Timer> timers) {
+
+        try {
+            Sample sample = rrdDB.createSample(System.currentTimeMillis() / 1000);
+            for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
+                update(sample, indexForName(entry.getKey()), entry.getValue());
+            }
+
+            for (Map.Entry<String, Counter> entry : counters.entrySet()) {
+                update(sample, indexForName(entry.getKey()), entry.getValue());
+            }
+
+            for (Map.Entry<String, Histogram> entry : histograms.entrySet()) {
+                update(sample, indexForName(entry.getKey()), entry.getValue());
+            }
+
+            for (Map.Entry<String, Meter> entry : meters.entrySet()) {
+                update(sample, indexForName(entry.getKey()), entry.getValue());
+            }
+
+            for (Map.Entry<String, Timer> entry : timers.entrySet()) {
+                update(sample, indexForName(entry.getKey()), entry.getValue());
+            }
+            sample.update();
+        } catch (IOException e) {
+            LOGGER.warn("Unable to write sample to RRD", e);
+        }
+    }
+
+    private int indexForName(String name) {
+        Integer idx = dictionary.get(normalize(name));
+        return idx != null ? idx : -1;
+    }
+
+    private static String normalize(String name) {
+        return name.replaceAll(":", "_");
+    }
+
+    private void update(Sample sample, int nameIdx, Gauge g) {
+        if (nameIdx < 0) {
+            return;
+        }
+        Object value = g.getValue();
+        if (value instanceof Number) {
+            sample.setValue(nameIdx, ((Number) value).doubleValue());
+        }
+    }
+
+    private void update(Sample sample, int nameIdx, Counter c) {
+        if (nameIdx < 0) {
+            return;
+        }
+        sample.setValue(nameIdx, c.getCount());
+    }
+
+    private void update(Sample sample, int nameIdx, Histogram h) {
+        if (nameIdx < 0) {
+            return;
+        }
+        sample.setValue(nameIdx, h.getCount());
+    }
+
+    private void update(Sample sample, int nameIdx, Timer t) {
+        if (nameIdx < 0) {
+            return;
+        }
+        sample.setValue(nameIdx, t.getCount());
+    }
+
+
+    private void update(Sample sample, int nameIdx, Meter m) {
+        if (nameIdx < 0) {
+            return;
+        }
+        LOGGER.debug("Sample: {} = {}", nameIdx, m.getCount());
+        sample.setValue(nameIdx, m.getCount());
+    }
+
+    private void storeDictionary(String path) throws IOException {
+        File dictFile = new File(path);
+        if (dictFile.exists() && ! dictFile.delete()) {
+            throw new IOException("Unable to delete dictionary file: " + dictFile.getPath());
+        }
+        Properties dict = new Properties();
+        for (Map.Entry<String, Integer> entry : dictionary.entrySet()) {
+            dict.put(String.valueOf(entry.getValue()), entry.getKey());
+        }
+        try (FileOutputStream out = new FileOutputStream(dictFile)) {
+            dict.store(out, "RRD4JReporter dictionary");
+        }
+    }
+
+    private RrdDb createDB(RrdDef definition) throws IOException {
+        File dbFile = new File(definition.getPath());
+        if (!dbFile.getParentFile().exists()) {
+            if (!dbFile.getParentFile().mkdirs()) {
+                throw new IOException("Unable to create directory for RRD file: " + dbFile.getParent());
+            }
+        }
+        RrdDb db = null;
+        if (dbFile.exists()) {
+            db = new RrdDb(definition.getPath());
+            if (!db.getRrdDef().equals(definition)) {
+                // definition changed -> re-create DB
+                db.close();
+                if (!dbFile.delete()) {
+                    throw new IOException("Unable to delete RRD file: " + dbFile.getPath());
+                }
+                LOGGER.warn("Configuration changed, recreating RRD file for metrics: " + dbFile.getPath());
+                db = null;
+            }
+        }
+        if (db == null) {
+            db = new RrdDb(definition);
+        }
+        return db;
+    }
+}
diff --git a/src/test/java/org/apache/sling/commons/metrics/rrd4j/impl/ReporterTest.java b/src/test/java/org/apache/sling/commons/metrics/rrd4j/impl/ReporterTest.java
new file mode 100644
index 0000000..2dffcf9
--- /dev/null
+++ b/src/test/java/org/apache/sling/commons/metrics/rrd4j/impl/ReporterTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.sling.commons.metrics.rrd4j.impl;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.MetricRegistry;
+
+import org.apache.sling.commons.metrics.rrd4j.impl.CodahaleMetricsReporter;
+import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.rrd4j.core.RrdDb;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class ReporterTest {
+
+    private static final File RRD = new File(new File("target", "metrics"), "metrics.rrd");
+
+    private static final long TEST_VALUE = 42;
+
+    @Rule
+    public final OsgiContext context = new OsgiContext();
+
+    private MetricRegistry registry = new MetricRegistry();
+
+    private CodahaleMetricsReporter reporter = new CodahaleMetricsReporter();
+
+    @Before
+    public void before() throws Exception {
+        RRD.delete();
+        context.registerService(MetricRegistry.class, registry, "name", "sling");
+
+        Map<String, Object> properties = new HashMap<>();
+        properties.put("step", 1L);
+        properties.put("datasources", new String[]{"DS:sling_myMetric:GAUGE:300:0:U"});
+        properties.put("archives", new String[]{"RRA:AVERAGE:0.5:1:60"});
+        properties.put("path", RRD.getPath());
+        context.registerInjectActivateService(reporter, properties);
+
+        registry.register("myMetric", new TestGauge(TEST_VALUE));
+    }
+
+    @Test
+    public void writeRRD() throws Exception {
+        assertTrue(RRD.exists());
+        for (int i = 0; i < 10; i++) {
+            RrdDb db = new RrdDb(RRD.getPath(), true);
+            try {
+                double lastValue = db.getDatasource("0").getLastValue();
+                if (lastValue == (double) TEST_VALUE) {
+                    return;
+                }
+            } finally {
+                db.close();
+            }
+            Thread.sleep(1000);
+        }
+        fail("RRD4J reporter did not update database in time");
+    }
+
+    private static final class TestGauge implements Gauge<Long> {
+
+        private final long value;
+
+        TestGauge(long value) {
+            this.value = value;
+        }
+
+        @Override
+        public Long getValue() {
+            return value;
+        }
+    }
+}
diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..bcb98cc
--- /dev/null
+++ b/src/test/resources/logback-test.xml
@@ -0,0 +1,30 @@
+<!--
+   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.
+  -->
+<configuration>
+
+    <appender name="file" class="ch.qos.logback.core.FileAppender">
+        <file>target/unit-tests.log</file>
+        <encoder>
+            <pattern>%date{HH:mm:ss.SSS} %-5level %-40([%thread] %F:%L) %msg%n</pattern>
+        </encoder>
+    </appender>
+
+    <root level="INFO">
+        <appender-ref ref="file"/>
+    </root>
+
+</configuration>