killbill-memoizeit

analytics: initial implementation of a dashboard Signed-off-by:

5/16/2013 5:26:20 PM

Changes

Details

diff --git a/osgi-bundles/bundles/analytics/pom.xml b/osgi-bundles/bundles/analytics/pom.xml
index d39c94d..3a2dd79 100644
--- a/osgi-bundles/bundles/analytics/pom.xml
+++ b/osgi-bundles/bundles/analytics/pom.xml
@@ -85,6 +85,11 @@
             <scope>runtime</scope>
         </dependency>
         <dependency>
+            <groupId>org.ini4j</groupId>
+            <artifactId>ini4j</artifactId>
+            <version>0.5.2</version>
+        </dependency>
+        <dependency>
             <groupId>org.osgi</groupId>
             <artifactId>org.osgi.core</artifactId>
         </dependency>
diff --git a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/AnalyticsActivator.java b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/AnalyticsActivator.java
index 4c52a44..6ceed5f 100644
--- a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/AnalyticsActivator.java
+++ b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/AnalyticsActivator.java
@@ -27,6 +27,9 @@ import org.osgi.framework.BundleContext;
 import com.ning.billing.osgi.api.OSGIPluginProperties;
 import com.ning.billing.osgi.bundles.analytics.api.user.AnalyticsUserApi;
 import com.ning.billing.osgi.bundles.analytics.http.AnalyticsServlet;
+import com.ning.billing.osgi.bundles.analytics.reports.ReportsConfiguration;
+import com.ning.billing.osgi.bundles.analytics.reports.ReportsUserApi;
+import com.ning.billing.osgi.bundles.analytics.reports.scheduler.JobsScheduler;
 import com.ning.killbill.osgi.libs.killbill.KillbillActivatorBase;
 import com.ning.killbill.osgi.libs.killbill.OSGIKillbillEventDispatcher.OSGIKillbillEventHandler;
 
@@ -35,6 +38,7 @@ public class AnalyticsActivator extends KillbillActivatorBase {
     public static final String PLUGIN_NAME = "killbill-analytics";
 
     private OSGIKillbillEventHandler analyticsListener;
+    private JobsScheduler jobsScheduler;
 
     @Override
     public void start(final BundleContext context) throws Exception {
@@ -45,12 +49,25 @@ public class AnalyticsActivator extends KillbillActivatorBase {
         analyticsListener = new AnalyticsListener(logService, killbillAPI, dataSource, executor);
         dispatcher.registerEventHandler(analyticsListener);
 
+        jobsScheduler = new JobsScheduler(logService, dataSource);
+        final ReportsConfiguration reportsConfiguration = new ReportsConfiguration(logService, jobsScheduler);
+        reportsConfiguration.initialize();
+
         final AnalyticsUserApi analyticsUserApi = new AnalyticsUserApi(logService, killbillAPI, dataSource, executor);
-        final AnalyticsServlet analyticsServlet = new AnalyticsServlet(analyticsUserApi, logService);
+        final ReportsUserApi reportsUserApi = new ReportsUserApi(dataSource, reportsConfiguration);
+        final AnalyticsServlet analyticsServlet = new AnalyticsServlet(analyticsUserApi, reportsUserApi, logService);
         registerServlet(context, analyticsServlet);
     }
 
     @Override
+    public void stop(final BundleContext context) throws Exception {
+        if (jobsScheduler != null) {
+            jobsScheduler.shutdownNow();
+        }
+        super.stop(context);
+    }
+
+    @Override
     public OSGIKillbillEventHandler getOSGIKillbillEventHandler() {
         return analyticsListener;
     }
diff --git a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/BusinessExecutor.java b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/BusinessExecutor.java
index 773d6ac..607b2c8 100644
--- a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/BusinessExecutor.java
+++ b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/BusinessExecutor.java
@@ -17,6 +17,7 @@
 package com.ning.billing.osgi.bundles.analytics;
 
 import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy;
 import java.util.concurrent.TimeUnit;
 
@@ -40,8 +41,8 @@ public class BusinessExecutor {
 
     }
 
-    public static Executor newSingleThreadScheduledExecutor() {
-        return Executors.newSingleThreadScheduledExecutor("osgi-analytics-reports",
+    public static ScheduledExecutorService newSingleThreadScheduledExecutor(final String name) {
+        return Executors.newSingleThreadScheduledExecutor(name,
                                                           new CallerRunsPolicy());
     }
 }
diff --git a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/http/AnalyticsServlet.java b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/http/AnalyticsServlet.java
index 7f72951..f6c3254 100644
--- a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/http/AnalyticsServlet.java
+++ b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/http/AnalyticsServlet.java
@@ -38,6 +38,7 @@ import com.ning.billing.osgi.bundles.analytics.AnalyticsRefreshException;
 import com.ning.billing.osgi.bundles.analytics.api.BusinessSnapshot;
 import com.ning.billing.osgi.bundles.analytics.api.user.AnalyticsUserApi;
 import com.ning.billing.osgi.bundles.analytics.json.NamedXYTimeSeries;
+import com.ning.billing.osgi.bundles.analytics.reports.ReportsUserApi;
 import com.ning.billing.util.callcontext.CallContext;
 import com.ning.billing.util.callcontext.CallOrigin;
 import com.ning.billing.util.callcontext.UserType;
@@ -65,13 +66,18 @@ public class AnalyticsServlet extends HttpServlet {
     private final static String QUERY_END_DATE = "endDate";
     private final static String QUERY_PRODUCTS = "products";
 
+    private static final String REPORTS = "reports";
+    private static final String REPORTS_QUERY_NAME = "name";
+
     private static final ObjectMapper mapper = ObjectMapperProvider.get();
 
     private final AnalyticsUserApi analyticsUserApi;
+    private final ReportsUserApi reportsUserApi;
     private final LogService logService;
 
-    public AnalyticsServlet(final AnalyticsUserApi analyticsUserApi, final LogService logService) {
+    public AnalyticsServlet(final AnalyticsUserApi analyticsUserApi, final ReportsUserApi reportsUserApi, final LogService logService) {
         this.analyticsUserApi = analyticsUserApi;
+        this.reportsUserApi = reportsUserApi;
         this.logService = logService;
     }
 
@@ -111,6 +117,8 @@ public class AnalyticsServlet extends HttpServlet {
 
         } else if (uriOperationInfo.startsWith(STATIC_RESOURCES)) {
             doHandleStaticResource(uriOperationInfo, resp);
+        } else if (uriOperationInfo.startsWith(REPORTS)) {
+            doHandleReports(req, resp);
         } else {
             final UUID kbAccountId = getKbAccountId(req, resp);
             final CallContext context = createCallContext(req, resp);
@@ -160,6 +168,21 @@ public class AnalyticsServlet extends HttpServlet {
         return res;
     }
 
+    private void doHandleReports(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException {
+        final String[] reportNames = req.getParameterValues(REPORTS_QUERY_NAME);
+        if (reportNames == null || reportNames.length == 0) {
+            resp.sendError(404);
+            return;
+        }
+
+        // TODO PIERRE Switch to an equivalent of StreamingOutputStream?
+        final List<NamedXYTimeSeries> result = reportsUserApi.getTimeSeriesDataForReport(reportNames);
+
+        resp.getOutputStream().write(mapper.writeValueAsBytes(result));
+        resp.setContentType("application/json");
+        setCrossSiteScriptingHeaders(resp);
+        resp.setStatus(HttpServletResponse.SC_OK);
+    }
 
     private void doHandlePlanTransitionsOverTime(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException {
 
diff --git a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/json/XY.java b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/json/XY.java
index 7ae8f20..fc3dee1 100644
--- a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/json/XY.java
+++ b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/json/XY.java
@@ -22,19 +22,23 @@ import com.fasterxml.jackson.annotation.JsonProperty;
 public class XY {
 
     private final String x;
-    private final Integer y;
+    private final Float y;
 
     @JsonCreator
-    public XY(@JsonProperty("x") final String x, @JsonProperty("y") final Integer y) {
+    public XY(@JsonProperty("x") final String x, @JsonProperty("y") final Float y) {
         this.x = x;
         this.y = y;
     }
 
+    public XY(final String x, final Integer y) {
+        this(x, new Float(y.doubleValue()));
+    }
+
     public String getX() {
         return x;
     }
 
-    public Integer getY() {
+    public Float getY() {
         return y;
     }
 }
diff --git a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportConfigurationSection.java b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportConfigurationSection.java
new file mode 100644
index 0000000..eb2a791
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportConfigurationSection.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning 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 com.ning.billing.osgi.bundles.analytics.reports;
+
+public interface ReportConfigurationSection {
+
+    public static enum Frequency {
+        HOURLY,
+        DAILY
+    }
+
+    public String getStoredProcedureName();
+
+    public Frequency getFrequency();
+
+    // For DAILY only
+    public Integer getRefreshTimeOfTheDayGMT();
+
+    public String getTableName();
+
+    public String getPrettyName();
+}
diff --git a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportsConfiguration.java b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportsConfiguration.java
new file mode 100644
index 0000000..4cabf7f
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportsConfiguration.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning 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 com.ning.billing.osgi.bundles.analytics.reports;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.ini4j.Ini;
+import org.ini4j.Profile.Section;
+import org.osgi.service.log.LogService;
+
+import com.ning.billing.osgi.bundles.analytics.reports.scheduler.JobsScheduler;
+import com.ning.killbill.osgi.libs.killbill.OSGIKillbillLogService;
+
+import com.google.common.base.Objects;
+
+public class ReportsConfiguration {
+
+    private static final String REPORTS_CONFIGURATION_FILE_PATH = System.getProperty("com.ning.billing.osgi.bundles.analytics.reports.configuration");
+
+    private final Map<String, ReportConfigurationSection> configurationPerReport = new LinkedHashMap<String, ReportConfigurationSection>();
+
+    private final OSGIKillbillLogService logService;
+    private final JobsScheduler scheduler;
+
+    public ReportsConfiguration(final OSGIKillbillLogService logService, final JobsScheduler scheduler) {
+        this.logService = logService;
+        this.scheduler = scheduler;
+    }
+
+    public void initialize() {
+        try {
+            parseConfigurationFile();
+        } catch (IOException e) {
+            logService.log(LogService.LOG_WARNING, "Error during initialization", e);
+        }
+    }
+
+    public String getTableNameForReport(final String reportName) {
+        if (configurationPerReport.get(reportName) != null) {
+            return configurationPerReport.get(reportName).getTableName();
+        } else {
+            return null;
+        }
+    }
+
+    public String getPrettyNameForReport(final String reportName) {
+        if (configurationPerReport.get(reportName) != null) {
+            return Objects.firstNonNull(configurationPerReport.get(reportName).getPrettyName(), reportName);
+        } else {
+            return reportName;
+        }
+    }
+
+    private void parseConfigurationFile() throws IOException {
+        if (REPORTS_CONFIGURATION_FILE_PATH == null) {
+            return;
+        }
+
+        final File configurationFile = new File(REPORTS_CONFIGURATION_FILE_PATH);
+        //noinspection MismatchedQueryAndUpdateOfCollection
+        final Ini ini = new Ini(configurationFile);
+        for (final String reportName : ini.keySet()) {
+            final Section section = ini.get(reportName);
+            Thread.currentThread().setContextClassLoader(ReportsConfiguration.class.getClassLoader());
+            final ReportConfigurationSection reportConfigurationSection = section.as(ReportConfigurationSection.class);
+
+            if (reportConfigurationSection.getFrequency() != null && reportConfigurationSection.getStoredProcedureName() != null) {
+                scheduler.schedule(reportName,
+                                   reportConfigurationSection.getStoredProcedureName(),
+                                   reportConfigurationSection.getFrequency(),
+                                   reportConfigurationSection.getRefreshTimeOfTheDayGMT());
+            }
+
+            configurationPerReport.put(reportName, reportConfigurationSection);
+        }
+    }
+}
diff --git a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportsUserApi.java b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportsUserApi.java
new file mode 100644
index 0000000..02f317a
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportsUserApi.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning 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 com.ning.billing.osgi.bundles.analytics.reports;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.joda.time.LocalDate;
+import org.skife.jdbi.v2.Handle;
+import org.skife.jdbi.v2.IDBI;
+
+import com.ning.billing.osgi.bundles.analytics.dao.BusinessDBIProvider;
+import com.ning.billing.osgi.bundles.analytics.json.NamedXYTimeSeries;
+import com.ning.billing.osgi.bundles.analytics.json.XY;
+import com.ning.killbill.osgi.libs.killbill.OSGIKillbillDataSource;
+
+public class ReportsUserApi {
+
+    private final IDBI dbi;
+    private final ReportsConfiguration reportsConfiguration;
+
+    public ReportsUserApi(final OSGIKillbillDataSource osgiKillbillDataSource,
+                          final ReportsConfiguration reportsConfiguration) {
+        this.reportsConfiguration = reportsConfiguration;
+        dbi = BusinessDBIProvider.get(osgiKillbillDataSource.getDataSource());
+    }
+
+    public List<NamedXYTimeSeries> getTimeSeriesDataForReport(final String[] reportNames) {
+        final Map<String, List<XY>> dataForReports = new LinkedHashMap<String, List<XY>>();
+
+        // TODO parallel
+        for (final String reportName : reportNames) {
+            final String tableName = reportsConfiguration.getTableNameForReport(reportName);
+            if (tableName != null) {
+                final List<XY> data = getData(tableName);
+                dataForReports.put(reportName, data);
+            }
+        }
+
+        normalizeXValues(dataForReports);
+
+        final List<NamedXYTimeSeries> results = new LinkedList<NamedXYTimeSeries>();
+        for (final String reportName : dataForReports.keySet()) {
+            results.add(new NamedXYTimeSeries(reportsConfiguration.getPrettyNameForReport(reportName), dataForReports.get(reportName)));
+        }
+        return results;
+    }
+
+    private void normalizeXValues(final Map<String, List<XY>> dataForReports) {
+        final Set<String> xValues = new HashSet<String>();
+        for (final List<XY> dataForReport : dataForReports.values()) {
+            for (final XY xy : dataForReport) {
+                xValues.add(xy.getX());
+            }
+        }
+
+        for (final List<XY> dataForReport : dataForReports.values()) {
+            for (final String x : xValues) {
+                if (!hasX(dataForReport, x)) {
+                    dataForReport.add(new XY(x, 0));
+                }
+            }
+        }
+
+        for (final String reportName : dataForReports.keySet()) {
+            Collections.sort(dataForReports.get(reportName), new Comparator<XY>() {
+                @Override
+                public int compare(final XY o1, final XY o2) {
+                    return new LocalDate(o1.getX()).compareTo(new LocalDate(o2.getX()));
+                }
+            });
+        }
+    }
+
+    private boolean hasX(final List<XY> values, final String x) {
+        for (final XY xy : values) {
+            if (xy.getX().equals(x)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private List<XY> getData(final String tableName) {
+        final List<XY> timeSeries = new LinkedList<XY>();
+
+        Handle handle = null;
+        try {
+            handle = dbi.open();
+            final List<Map<String, Object>> results = handle.select("select day, count from " + tableName);
+            for (final Map<String, Object> row : results) {
+                if (row.get("day") == null || row.get("count") == null) {
+                    continue;
+                }
+
+                final String date = row.get("day").toString();
+                final Float value = Float.valueOf(row.get("count").toString());
+                timeSeries.add(new XY(date, value));
+            }
+        } finally {
+            if (handle != null) {
+                handle.close();
+            }
+        }
+
+        return timeSeries;
+    }
+}
diff --git a/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/scheduler/JobsScheduler.java b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/scheduler/JobsScheduler.java
new file mode 100644
index 0000000..be148fc
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/scheduler/JobsScheduler.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning 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 com.ning.billing.osgi.bundles.analytics.reports.scheduler;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.osgi.service.log.LogService;
+import org.skife.jdbi.v2.Call;
+import org.skife.jdbi.v2.Handle;
+import org.skife.jdbi.v2.IDBI;
+
+import com.ning.billing.osgi.bundles.analytics.BusinessExecutor;
+import com.ning.billing.osgi.bundles.analytics.dao.BusinessDBIProvider;
+import com.ning.billing.osgi.bundles.analytics.reports.ReportConfigurationSection.Frequency;
+import com.ning.killbill.osgi.libs.killbill.OSGIKillbillDataSource;
+import com.ning.killbill.osgi.libs.killbill.OSGIKillbillLogService;
+
+public class JobsScheduler {
+
+    private final List<ScheduledExecutorService> jobs = new LinkedList<ScheduledExecutorService>();
+
+    private final OSGIKillbillLogService logService;
+    private final IDBI dbi;
+
+    public JobsScheduler(final OSGIKillbillLogService logService,
+                         final OSGIKillbillDataSource osgiKillbillDataSource) {
+        this.logService = logService;
+        dbi = BusinessDBIProvider.get(osgiKillbillDataSource.getDataSource());
+    }
+
+    public void shutdownNow() {
+        for (final ScheduledExecutorService executor : jobs) {
+            executor.shutdownNow();
+        }
+    }
+
+    public void schedule(final String reportName, final String storedProcedureName, final Frequency frequency, final Integer refreshTimeOfTheDayGMT) {
+        final ScheduledExecutorService executor = BusinessExecutor.newSingleThreadScheduledExecutor("osgi-analytics-reports-" + reportName);
+        jobs.add(executor);
+
+        final StoredProcedureJob command = new StoredProcedureJob(logService, reportName, storedProcedureName, frequency, refreshTimeOfTheDayGMT);
+        executor.scheduleAtFixedRate(command, 0, 1, TimeUnit.MINUTES);
+    }
+
+    private final class StoredProcedureJob implements Runnable {
+
+        private final AtomicLong lastRun = new AtomicLong(0);
+
+        private final LogService logService;
+        private final String reportName;
+        private final String storedProcedureName;
+        private final Frequency frequency;
+        private final Integer refreshTimeOfTheDayGMT;
+
+        private StoredProcedureJob(final LogService logService, final String reportName, final String storedProcedureName,
+                                   final Frequency frequency, final Integer refreshTimeOfTheDayGMT) {
+            this.logService = logService;
+            this.reportName = reportName;
+            this.storedProcedureName = storedProcedureName;
+            this.frequency = frequency;
+            this.refreshTimeOfTheDayGMT = refreshTimeOfTheDayGMT;
+        }
+
+        @Override
+        public void run() {
+            if (!shouldRun()) {
+                return;
+            }
+
+            logService.log(LogService.LOG_INFO, "Starting job for " + reportName);
+
+            callStoredProcedure(storedProcedureName);
+            lastRun.set(System.currentTimeMillis());
+
+            logService.log(LogService.LOG_INFO, "Ending job for " + reportName);
+        }
+
+        private boolean shouldRun() {
+            if (Frequency.HOURLY.equals(frequency) && (System.currentTimeMillis() - lastRun.get()) >= 3600000) {
+                return true;
+            } else if (Frequency.DAILY.equals(frequency)) {
+                if (refreshTimeOfTheDayGMT == null && (System.currentTimeMillis() - lastRun.get()) >= 86400000) {
+                    return true;
+                } else if (refreshTimeOfTheDayGMT != null &&
+                           new DateTime(DateTimeZone.UTC).getHourOfDay() == refreshTimeOfTheDayGMT &&
+                           (System.currentTimeMillis() - lastRun.get()) >= 3600000) {
+                    return true;
+                } else {
+                    return false;
+                }
+            } else {
+                return false;
+            }
+        }
+    }
+
+    private void callStoredProcedure(final String storedProcedureName) {
+        Handle handle = null;
+        try {
+            handle = dbi.open();
+            final Call call = handle.createCall(storedProcedureName);
+            call.invoke();
+        } finally {
+            if (handle != null) {
+                handle.close();
+            }
+        }
+    }
+}
diff --git a/osgi-bundles/bundles/analytics/src/main/resources/reports/analytics.ini b/osgi-bundles/bundles/analytics/src/main/resources/reports/analytics.ini
new file mode 100644
index 0000000..3824b19
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/resources/reports/analytics.ini
@@ -0,0 +1,33 @@
+;
+; Copyright 2010-2013 Ning, Inc.
+;
+; Ning 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.
+;
+
+; Built-in default set of reports
+
+[new_accounts_per_day]
+tableName = v_new_accounts_per_day
+prettyName = Accounts created per day
+
+[new_trials_per_day]
+tableName = v_new_trials_per_day
+prettyName = Trials created per day
+
+[conversions_per_day]
+tableName = v_conversions_per_day
+prettyName = Conversions per day
+
+[cancellations_per_day]
+tableName = v_cancellations_per_day
+prettyName = Effective cancellations per day
diff --git a/osgi-bundles/bundles/analytics/src/main/resources/reports/cancellations_per_day.sql b/osgi-bundles/bundles/analytics/src/main/resources/reports/cancellations_per_day.sql
new file mode 100644
index 0000000..6d70953
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/resources/reports/cancellations_per_day.sql
@@ -0,0 +1,10 @@
+create or replace view v_cancellations_per_day as
+select
+  date_format(next_start_date, '%Y-%m-%d') as day
+, count(*) as count
+from bst
+where next_start_date > date_sub(curdate(), interval 90 day)
+and event = 'CANCEL_BASE'
+group by 1
+order by 1 asc
+;
diff --git a/osgi-bundles/bundles/analytics/src/main/resources/reports/conversions_per_day.sql b/osgi-bundles/bundles/analytics/src/main/resources/reports/conversions_per_day.sql
new file mode 100644
index 0000000..d748841
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/resources/reports/conversions_per_day.sql
@@ -0,0 +1,12 @@
+create or replace view v_conversions_per_day as
+select
+  date_format(next_start_date, '%Y-%m-%d') as day
+, count(*) as count
+from bst
+where next_start_date > date_sub(curdate(), interval 90 day)
+and event = 'SYSTEM_CHANGE_BASE'
+and prev_phase = 'TRIAL'
+and next_phase = 'EVERGREEN'
+group by 1
+order by 1 asc
+;
diff --git a/osgi-bundles/bundles/analytics/src/main/resources/reports/new_accounts_per_day.sql b/osgi-bundles/bundles/analytics/src/main/resources/reports/new_accounts_per_day.sql
new file mode 100644
index 0000000..ea45445
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/resources/reports/new_accounts_per_day.sql
@@ -0,0 +1,9 @@
+create or replace view v_new_accounts_per_day as
+select
+  date_format(created_date, '%Y-%m-%d') as day
+, count(*) as count
+from bac
+where created_date > date_sub(curdate(), interval 90 day)
+group by 1
+order by 1 asc
+;
diff --git a/osgi-bundles/bundles/analytics/src/main/resources/reports/new_trials_per_day.sql b/osgi-bundles/bundles/analytics/src/main/resources/reports/new_trials_per_day.sql
new file mode 100644
index 0000000..f385462
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/resources/reports/new_trials_per_day.sql
@@ -0,0 +1,10 @@
+create or replace view v_new_trials_per_day as
+select
+  date_format(next_start_date, '%Y-%m-%d') as day
+, count(*) as count
+from bst
+where next_start_date > date_sub(curdate(), interval 90 day)
+and event = 'ADD_BASE'
+group by 1
+order by 1 asc
+;
diff --git a/osgi-bundles/bundles/analytics/src/main/resources/static/analytics.html b/osgi-bundles/bundles/analytics/src/main/resources/static/analytics.html
new file mode 100644
index 0000000..7ff63e2
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/resources/static/analytics.html
@@ -0,0 +1,130 @@
+<!DOCTYPE html>
+<!--
+  ~ Copyright 2010-2013 Ning, Inc.
+  ~
+  ~ Ning 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.
+  -->
+
+<html lang="en">
+<head>
+    <meta charset="utf-8"/>
+    <title>Kill Bill analytics dashboards</title>
+
+    <script type="text/javascript" src="javascript/d3.js"></script>
+    <script type="text/javascript" src="javascript/jquery-1.9.0.min.js"></script>
+    <script type="text/javascript" src="javascript/purl.js"></script>
+    <script type="text/javascript" src="javascript/killbill.js"></script>
+
+    <link rel="stylesheet" type="text/css" href="styles/dashboard.css" media="screen"/>
+</head>
+<body>
+    <div id="chartAnchor"></div>
+
+    <script type="text/javascript">
+        function createLinesGraph(data, graphStructure, canvas, input, canvasHeightGraph, translateX, translateY, translateLabelY) {
+          console.log(data);
+          var canvasGroup = graphStructure.createCanvasGroup(canvas, translateX, translateY);
+          var linesGraph = new killbillGraph.KBLinesGraph(canvasGroup, data, input.canvasWidth, canvasHeightGraph, d3.scale.category20b());
+          linesGraph.drawLines();
+          linesGraph.addLabels("labelsLine", translateLabelY);
+          linesGraph.addMouseOverCircleForValue();
+          return linesGraph;
+        }
+
+        function drawAll(input, dataForAllReports) {
+          var graphStructure = new killbillGraph.GraphStructure();
+          graphStructure.setupDomStructure();
+
+          // Positions
+          var canvasHeightWithMargins = input.canvasHeigth + input.topMargin + input.betweenGraphMargin + input.bottomMargin;
+          var canvas = graphStructure.createCanvas([input.topMargin, input.rightMargin, input.bottomMargin, input.leftMargin], input.canvasWidth, canvasHeightWithMargins);
+          var canvasHeightGraph = input.canvasHeigth / 2;
+          var translateX = input.leftMargin;
+          var translateY = input.topMargin;
+          var translateLabelY = translateY + (canvasHeightGraph / 2);
+
+          for (var idx in dataForAllReports) {
+            var lastGraph = createLinesGraph(dataForAllReports[idx], graphStructure, canvas, input, canvasHeightGraph, translateX, translateY, translateLabelY);
+            translateY = translateY + canvasHeightGraph + input.betweenGraphMargin;
+            translateLabelY = translateLabelY + input.betweenGraphMargin;
+          }
+
+          // Bottom, shared, X axis
+          var xAxisCanvasGroup = graphStructure.createCanvasGroup(canvas, translateX, translateY);
+          lastGraph.createXAxis(xAxisCanvasGroup, 2 * (canvasHeightGraph + input.betweenGraphMargin));
+        }
+
+        // Get the data for a set of reports
+        function doGetData(position, reports, fn) {
+          var request_url = "http://" + $VAR_SERVER + ":" + $VAR_PORT + "/plugins/killbill-analytics/reports?name=" + reports.join("&name=");
+
+          return $.ajax({
+            type: "GET",
+            contentType: "application/json",
+            url: request_url
+          }).done(function(data) { fn(position, reports, data); })
+            .fail(function(jqXHR, textStatus) { alert("Request failed: " + textStatus); });
+        }
+
+        // The URL structure is expected to be in the form: analytics.html?report1=new_trials_per_day&report1=cancellations_per_day&report2=conversions_per_day
+        $(document).ready(function() {
+          // Map of position (starting from the top) to an array of reports
+          var reports = {}
+          for (var i = 1; i < 10; i++) {
+            var reportsI = $.url().param('report' + i);
+            if (!reportsI) {
+              // No more reports
+              break;
+            } else if (reportsI instanceof Array) {
+              reports[i] = reportsI;
+            } else {
+              reports[i] = [reportsI];
+            }
+          }
+
+          // Set sane defaults
+          if (!reports[1]) {
+            // Built-in set of reports
+            reports[1] = ["new_trials_per_day", "cancellations_per_day", "conversions_per_day"];
+          }
+
+          // Array of all deferreds
+          var futures = []
+          // Map of position (starting from the top) to the data
+          var futuresData = {}
+          for (var position in reports) {
+            // Fetch the data
+            var future = doGetData(position, reports[position], function(zePosition, reports, reportsData) {
+              if (!(reportsData instanceof Array) || reportsData.length == 0) {
+                futuresData[zePosition] = [ { "name": "No data", "values": [] }];
+              } else {
+                futuresData[zePosition] = reportsData;
+              }
+            });
+            futures.push(future);
+          }
+
+          $.when.apply(null, futures).done(function() {
+            var dataForAllReports = [];
+            for (var position in reports) {
+              // Index starts at zero
+              dataForAllReports[position - 1] = futuresData[position];
+            }
+
+            var input = new killbillGraph.KBInputGraphs(800, 600, 80, 80, 80, 80, 30, dataForAllReports);
+            drawAll(input, dataForAllReports);
+          });
+        });
+    </script>
+</body>
+</html>
diff --git a/osgi-bundles/bundles/analytics/src/main/resources/static/javascript/purl.js b/osgi-bundles/bundles/analytics/src/main/resources/static/javascript/purl.js
new file mode 100644
index 0000000..509ca08
--- /dev/null
+++ b/osgi-bundles/bundles/analytics/src/main/resources/static/javascript/purl.js
@@ -0,0 +1,271 @@
+/*
+ * JQuery URL Parser plugin, v2.2.1
+ * Developed and maintanined by Mark Perkins, mark@allmarkedup.com
+ * Source repository: https://github.com/allmarkedup/jQuery-URL-Parser
+ * Licensed under an MIT-style license. See https://github.com/allmarkedup/jQuery-URL-Parser/blob/master/LICENSE for details.
+ */ 
+
+;(function(factory) {
+	if (typeof define === 'function' && define.amd) {
+		// AMD available; use anonymous module
+		if ( typeof jQuery !== 'undefined' ) {
+			define(['jquery'], factory);	
+		} else {
+			define([], factory);
+		}
+	} else {
+		// No AMD available; mutate global vars
+		if ( typeof jQuery !== 'undefined' ) {
+			factory(jQuery);
+		} else {
+			factory();
+		}
+	}
+})(function($, undefined) {
+	
+	var tag2attr = {
+			a       : 'href',
+			img     : 'src',
+			form    : 'action',
+			base    : 'href',
+			script  : 'src',
+			iframe  : 'src',
+			link    : 'href'
+		},
+		
+		key = ['source', 'protocol', 'authority', 'userInfo', 'user', 'password', 'host', 'port', 'relative', 'path', 'directory', 'file', 'query', 'fragment'], // keys available to query
+		
+		aliases = { 'anchor' : 'fragment' }, // aliases for backwards compatability
+		
+		parser = {
+			strict : /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/,  //less intuitive, more accurate to the specs
+			loose :  /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/ // more intuitive, fails on relative paths and deviates from specs
+		},
+		
+		toString = Object.prototype.toString,
+		
+		isint = /^[0-9]+$/;
+	
+	function parseUri( url, strictMode ) {
+		var str = decodeURI( url ),
+		res   = parser[ strictMode || false ? 'strict' : 'loose' ].exec( str ),
+		uri = { attr : {}, param : {}, seg : {} },
+		i   = 14;
+		
+		while ( i-- ) {
+			uri.attr[ key[i] ] = res[i] || '';
+		}
+		
+		// build query and fragment parameters		
+		uri.param['query'] = parseString(uri.attr['query']);
+		uri.param['fragment'] = parseString(uri.attr['fragment']);
+		
+		// split path and fragement into segments		
+		uri.seg['path'] = uri.attr.path.replace(/^\/+|\/+$/g,'').split('/');     
+		uri.seg['fragment'] = uri.attr.fragment.replace(/^\/+|\/+$/g,'').split('/');
+		
+		// compile a 'base' domain attribute        
+		uri.attr['base'] = uri.attr.host ? (uri.attr.protocol ?  uri.attr.protocol+'://'+uri.attr.host : uri.attr.host) + (uri.attr.port ? ':'+uri.attr.port : '') : '';      
+		  
+		return uri;
+	};
+	
+	function getAttrName( elm ) {
+		var tn = elm.tagName;
+		if ( typeof tn !== 'undefined' ) return tag2attr[tn.toLowerCase()];
+		return tn;
+	}
+	
+	function promote(parent, key) {
+		if (parent[key].length == 0) return parent[key] = {};
+		var t = {};
+		for (var i in parent[key]) t[i] = parent[key][i];
+		parent[key] = t;
+		return t;
+	}
+
+	function parse(parts, parent, key, val) {
+		var part = parts.shift();
+		if (!part) {
+			if (isArray(parent[key])) {
+				parent[key].push(val);
+			} else if ('object' == typeof parent[key]) {
+				parent[key] = val;
+			} else if ('undefined' == typeof parent[key]) {
+				parent[key] = val;
+			} else {
+				parent[key] = [parent[key], val];
+			}
+		} else {
+			var obj = parent[key] = parent[key] || [];
+			if (']' == part) {
+				if (isArray(obj)) {
+					if ('' != val) obj.push(val);
+				} else if ('object' == typeof obj) {
+					obj[keys(obj).length] = val;
+				} else {
+					obj = parent[key] = [parent[key], val];
+				}
+			} else if (~part.indexOf(']')) {
+				part = part.substr(0, part.length - 1);
+				if (!isint.test(part) && isArray(obj)) obj = promote(parent, key);
+				parse(parts, obj, part, val);
+				// key
+			} else {
+				if (!isint.test(part) && isArray(obj)) obj = promote(parent, key);
+				parse(parts, obj, part, val);
+			}
+		}
+	}
+
+	function merge(parent, key, val) {
+		if (~key.indexOf(']')) {
+			var parts = key.split('['),
+			len = parts.length,
+			last = len - 1;
+			parse(parts, parent, 'base', val);
+		} else {
+			if (!isint.test(key) && isArray(parent.base)) {
+				var t = {};
+				for (var k in parent.base) t[k] = parent.base[k];
+				parent.base = t;
+			}
+			set(parent.base, key, val);
+		}
+		return parent;
+	}
+
+	function parseString(str) {
+		return reduce(String(str).split(/&|;/), function(ret, pair) {
+			try {
+				pair = decodeURIComponent(pair.replace(/\+/g, ' '));
+			} catch(e) {
+				// ignore
+			}
+			var eql = pair.indexOf('='),
+				brace = lastBraceInKey(pair),
+				key = pair.substr(0, brace || eql),
+				val = pair.substr(brace || eql, pair.length),
+				val = val.substr(val.indexOf('=') + 1, val.length);
+
+			if ('' == key) key = pair, val = '';
+
+			return merge(ret, key, val);
+		}, { base: {} }).base;
+	}
+	
+	function set(obj, key, val) {
+		var v = obj[key];
+		if (undefined === v) {
+			obj[key] = val;
+		} else if (isArray(v)) {
+			v.push(val);
+		} else {
+			obj[key] = [v, val];
+		}
+	}
+	
+	function lastBraceInKey(str) {
+		var len = str.length,
+			 brace, c;
+		for (var i = 0; i < len; ++i) {
+			c = str[i];
+			if (']' == c) brace = false;
+			if ('[' == c) brace = true;
+			if ('=' == c && !brace) return i;
+		}
+	}
+	
+	function reduce(obj, accumulator){
+		var i = 0,
+			l = obj.length >> 0,
+			curr = arguments[2];
+		while (i < l) {
+			if (i in obj) curr = accumulator.call(undefined, curr, obj[i], i, obj);
+			++i;
+		}
+		return curr;
+	}
+	
+	function isArray(vArg) {
+		return Object.prototype.toString.call(vArg) === "[object Array]";
+	}
+	
+	function keys(obj) {
+		var keys = [];
+		for ( prop in obj ) {
+			if ( obj.hasOwnProperty(prop) ) keys.push(prop);
+		}
+		return keys;
+	}
+		
+	function purl( url, strictMode ) {
+		if ( arguments.length === 1 && url === true ) {
+			strictMode = true;
+			url = undefined;
+		}
+		strictMode = strictMode || false;
+		url = url || window.location.toString();
+	
+		return {
+			
+			data : parseUri(url, strictMode),
+			
+			// get various attributes from the URI
+			attr : function( attr ) {
+				attr = aliases[attr] || attr;
+				return typeof attr !== 'undefined' ? this.data.attr[attr] : this.data.attr;
+			},
+			
+			// return query string parameters
+			param : function( param ) {
+				return typeof param !== 'undefined' ? this.data.param.query[param] : this.data.param.query;
+			},
+			
+			// return fragment parameters
+			fparam : function( param ) {
+				return typeof param !== 'undefined' ? this.data.param.fragment[param] : this.data.param.fragment;
+			},
+			
+			// return path segments
+			segment : function( seg ) {
+				if ( typeof seg === 'undefined' ) {
+					return this.data.seg.path;
+				} else {
+					seg = seg < 0 ? this.data.seg.path.length + seg : seg - 1; // negative segments count from the end
+					return this.data.seg.path[seg];                    
+				}
+			},
+			
+			// return fragment segments
+			fsegment : function( seg ) {
+				if ( typeof seg === 'undefined' ) {
+					return this.data.seg.fragment;                    
+				} else {
+					seg = seg < 0 ? this.data.seg.fragment.length + seg : seg - 1; // negative segments count from the end
+					return this.data.seg.fragment[seg];                    
+				}
+			}
+	    	
+		};
+	
+	};
+	
+	if ( typeof $ !== 'undefined' ) {
+		
+		$.fn.url = function( strictMode ) {
+			var url = '';
+			if ( this.length ) {
+				url = $(this).attr( getAttrName(this[0]) ) || '';
+			}    
+			return purl( url, strictMode );
+		};
+		
+		$.url = purl;
+		
+	} else {
+		window.purl = purl;
+	}
+
+});
+
diff --git a/osgi-bundles/bundles/analytics/src/test/java/com/ning/billing/osgi/bundles/analytics/http/TestAnalyticsServlet.java b/osgi-bundles/bundles/analytics/src/test/java/com/ning/billing/osgi/bundles/analytics/http/TestAnalyticsServlet.java
index f0a92df..5065846 100644
--- a/osgi-bundles/bundles/analytics/src/test/java/com/ning/billing/osgi/bundles/analytics/http/TestAnalyticsServlet.java
+++ b/osgi-bundles/bundles/analytics/src/test/java/com/ning/billing/osgi/bundles/analytics/http/TestAnalyticsServlet.java
@@ -62,7 +62,7 @@ public class TestAnalyticsServlet {
         mapper.writeValue(writer, res);
 
         Assert.assertEquals(writer.toString(),
-                            "[{\"name\":\"serie1\",\"values\":[{\"x\":\"2013-01-01\",\"y\":11},{\"x\":\"2013-01-02\",\"y\":7},{\"x\":\"2013-01-03\",\"y\":34}]},{\"name\":\"serie2\",\"values\":[{\"x\":\"2013-01-01\",\"y\":12},{\"x\":\"2013-01-02\",\"y\":5},{\"x\":\"2013-01-03\",\"y\":3}]}]");
+                            "[{\"name\":\"serie1\",\"values\":[{\"x\":\"2013-01-01\",\"y\":11.0},{\"x\":\"2013-01-02\",\"y\":7.0},{\"x\":\"2013-01-03\",\"y\":34.0}]},{\"name\":\"serie2\",\"values\":[{\"x\":\"2013-01-01\",\"y\":12.0},{\"x\":\"2013-01-02\",\"y\":5.0},{\"x\":\"2013-01-03\",\"y\":3.0}]}]");
 
     }