killbill-memoizeit
Changes
osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/AnalyticsActivator.java 19(+18 -1)
osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/BusinessExecutor.java 5(+3 -2)
osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/http/AnalyticsServlet.java 25(+24 -1)
osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/json/XY.java 10(+7 -3)
osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportConfigurationSection.java 36(+36 -0)
osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportsConfiguration.java 94(+94 -0)
osgi-bundles/bundles/analytics/src/main/java/com/ning/billing/osgi/bundles/analytics/reports/ReportsUserApi.java 128(+128 -0)
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}]}]");
}