Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions analytics-webapps/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
<artifactId>analytics-webapps</artifactId>
<packaging>war</packaging>
<name>Meeds:: Analytics - Application</name>
<properties>
<!-- Same as analytics-api and analytics-services: the parent defaults
this gate to 1.0, so jacoco:check fails any module that has tests
at all without covering every instruction. -->
<exo.test.coverage.ratio>0</exo.test.coverage.ratio>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@
package io.meeds.analytics.portlet;

import java.io.IOException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;

import javax.portlet.*;
import javax.ws.rs.core.MediaType;

import org.apache.commons.lang3.StringUtils;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Workbook;
import org.json.*;

import org.exoplatform.commons.utils.CommonsUtils;
Expand Down Expand Up @@ -69,6 +76,15 @@ public abstract class AbstractAnalyticsPortlet<T> extends GenericPortlet {

private static final String EXPORT_EXCEL_OPERATION = "EXPORT_EXCEL";

/**
* Both exports write an OOXML workbook (XSSF, ".xlsx"). Declaring the
* legacy "application/vnd.ms-excel" type of the binary ".xls" format makes
* Excel greet the download with a "the file format and the extension don't
* match" warning before opening it.
*/
protected static final String XLSX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

private SpaceService spaceService;

private AnalyticsService analyticsService;
Expand Down Expand Up @@ -397,6 +413,134 @@ private T clone(T filter) {
}
}

/**
* Excel number format per date-histogram interval, for the intervals a
* spreadsheet can render faithfully from a real date value.
* <p>
* The day format is Excel's builtin "m/d/yy" (format index 14), which
* Excel renders using the *reader's* own short-date convention rather
* than the literal pattern, so a French and an English reader each see
* their own. The coarser ones spell out a pattern because no locale-aware
* builtin exists for them.
* <p>
* Absent on purpose: quarter and ISO-week (no faithful spreadsheet format
* token — a real date value would display as its first day, losing the
* "Q3 2026" / "W37-2026" the chart shows) and hour, whose bucket key is an
* hour of day (0-23) cumulated over the period, not an instant. Those keep
* the textual label.
*/
private static final Map<String, String> EXCEL_DATE_FORMATS = Map.of(AnalyticsAggregation.YEAR_INTERVAL,
"yyyy",
AnalyticsAggregation.MONTH_INTERVAL,
"mmm yyyy",
AnalyticsAggregation.DAY_INTERVAL,
"m/d/yy",
AnalyticsAggregation.MINUTE_INTERVAL,
"yyyy-mm-dd hh:mm",
AnalyticsAggregation.SECOND_INTERVAL,
"yyyy-mm-dd hh:mm:ss");

/**
Comment thread
ahamdi marked this conversation as resolved.
* Writes an epoch-milliseconds value as a real date-time cell.
* <p>
* Used for a column aggregating a date *field* (a MAX over a "last
* connection" field, say): the aggregation type is MAX, not DATE, so it is
* not a date histogram and has no interval — but its value is still an
* instant, and written as a plain number it reaches the reader as
* 1.75941E+12.
*
* @return {@code true} when the cell was written as a date, {@code false}
* when the value is not epoch millis and the caller should fall
* back
*/
protected boolean writeTimestampCell(Cell cell, String value, ZoneId zoneId, Map<String, CellStyle> styles) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High — a MIN/MAX over a date field never reaches the reader as a date: the table path is missing the BigDecimal handling the chart path already has

This is the case writeTimestampCell's own javadoc was written for — "a MAX over a last connection field, say ... written as a plain number it reaches the reader as 1.75941E+12" — and it is the one case that still does exactly that.

The two paths that read an aggregation's value diverge. The chart path converts the scientific-notation form:

Object value = valueResult.get(VALUE_PARAM);
if (value instanceof BigDecimal bd) {
  result = bd.toPlainString();        // "1759410000000"
} else {
  result = value.toString();
}

The table path does not:

value = bucket.getJSONObject(AGGREGATION_RESULT_VALUE_PARAM).get(VALUE_PARAM);
...
itemValue.setValue(toString(value));   // private String toString(Object v) { return Objects.toString(v, null); }

Verified against the engines rather than from memory. A real elasticsearch:9.4.0, indexed with this repo's own analytics-es-template.json field definitions ("timestamp": {"type":"date","format":"epoch_millis"}), answers a metric aggregation over that field like this — verbatim:

"max_ts":{"value":1.759410123456E12,"value_as_string":"1759410123456"},
"min_ts":{"value":1.75941E12,"value_as_string":"1759410000000"},
"sum_ts":{"value":3.518820123456E12,"value_as_string":"3518820123456"}

and the same literals appear in the nested terms-bucket shape this portlet actually builds. Feeding that value through the exact artifact the build resolves, json-20231013.jar:

new JSONObject("{\"value\":1.75941E12}").get("value")
  class            = java.math.BigDecimal
  Objects.toString -> "1.75941E+12"      <- what the table path stores
  toPlainString()  -> "1759410000000"    <- what the chart path stores
  Long.parseLong("1.75941E+12") -> NumberFormatException

So isDateColumn correctly classifies the column as a date column, writeValue calls writeTimestampCell, Long.parseLong throws, it returns false, and the value falls through to Double.parseDouble — a bare number with no date format. Reachable and ordinary: the settings UI offers MIN and MAX, and FieldSelection.vue:142 (!this.numeric || field.numeric || field.date) offers date fields for them.

AnalyticsTableExportCellTest:160-172 stays green because it feeds writeValue a hand-typed "1789055100000" — a shape this pipeline never produces for an aggregation-backed column. The only values that genuinely arrive as plain epoch millis are space.getCreatedTime() and profile.getCreatedTime(), which are the two the suite does exercise. That hand-typed string is precisely what hid this.

This defect predates this round and was not raised in Round #1 — fixing it here or splitting it into its own task is a scope call for the Architect, not for this review.

Fix (hypothesis to confirm — the two options are not equivalent): mirroring the chart path (BigDecimal.toPlainString() in computeColumnItemValue) works for any date field. Reading value_as_string instead looks cleaner and matches how getResultKeyAsString already prefers key_as_string — but that field is only present because timestamp carries format: epoch_millis; a date field mapped with any other format would yield a differently-shaped string that writeTimestampCell still could not parse, and it is absent altogether on non-date fields. The toPlainString() mirror is the safer of the two. Either way, pin it with a test driven from a captured ES response body rather than a hand-typed epoch string.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5f0bc32c4. I reproduced the mechanism against the artifact the build actually resolves rather than taking the ES capture on trust — json-20231013.jar, the three literal shapes:

1.75941E12     -> BigDecimal  toString="1.75941E+12"  Long.parseLong -> NumberFormatException
1789055100000  -> Long        toString="1789055100000" Long.parseLong -> 1789055100000
1.7890551E12   -> BigDecimal  toString="1.7890551E+12" Long.parseLong -> NumberFormatException

So the failure is exactly as you describe, and it explains something I had wrong: the reporter saw Last connection export correctly at an earlier commit, which I read as "the MIN/MAX path works". Whether a given value lands as a Long or a BigDecimal depends on the literal Elasticsearch emits, so that column working was luck of the value, not the path being sound.

Fixed with the toPlainString equivalent at the parse site: new BigDecimal(value).longValueExact(). longValueExact is deliberate — it keeps a fractional value (an average of counts) from being read as an instant, which a plain longValue() would truncate into one. Pinned both ways: a scientific-notation value comes back as a date, 8.5 does not.

On your two options, I took neither literally. value_as_string I left alone for the reason you give — it exists only because timestamp carries format: epoch_millis. But I did not mirror the chart path in computeColumnItemValue either: that is a shared service whose output also feeds the live table, and changing it is a wider blast radius than this PR should carry on a defect it did not introduce. Parsing robustly at the single point that turns a value into a date cell fixes the reader-visible defect with the smaller change. The divergence you name — table path storing scientific notation where the chart path stores plain digits — is therefore still there, deliberately. If the Architect would rather close it at the source, that is a one-line change in computeColumnItemValue and I will make it.

Mutation-verified: restoring Long.parseLong fails testAnAggregationValueInScientificNotationIsStillAnInstant.

long timestamp;
try {
// Parsed as a decimal, not with Long.parseLong: a metric aggregation's
// value arrives from Elasticsearch as a JSON floating-point literal and
// org.json turns it into a BigDecimal, whose toString is scientific
// notation ("1.75941E+12"). Long.parseLong rejects that, which left the
// one case this method exists for - a MIN/MAX over a date field -
// falling through to a plain number in the reader's spreadsheet.
timestamp = new BigDecimal(StringUtils.trim(value)).longValueExact();
} catch (NumberFormatException | ArithmeticException e) {
// Not a whole number of milliseconds: not an instant
return false;
}
if (timestamp <= 0) {
// A "never connected" style zero is not a date, and would export as
// 1 January 1970
return false;
}
writeDateValue(cell, timestamp, zoneId, styles, "yyyy-mm-dd hh:mm");
return true;
}

/**
* Writes a date bucket as a real date-typed cell instead of the localized
* label the chart displays.
* <p>
* A label such as "1 sept. 2026" written as text is only a picture of a
* date to a spreadsheet: it cannot be sorted chronologically (it sorts
* lexicographically, so "10 août" lands before "1 sept."), filtered by
* period, or fed to a date formula, and no cell formatting recovers it
* because the underlying value is a string. A date-typed cell carries the
* instant itself and each reader's Excel renders it in their own locale.
*
* @param cell cell to write
* @param aggregation the aggregation the bucket belongs to
* @param key the raw bucket key, epoch milliseconds for a date
* histogram
* @param zoneId time zone the buckets were aligned on, so the written
* wall-clock date is the one the chart shows
* @param styles per-workbook cache of the created cell styles: a
* workbook holds a bounded number of them, so one per
* cell would both bloat the file and eventually hit
* that limit
* @return {@code true} when the cell was written as a date, {@code false}
* when this bucket has no faithful date representation and the
* caller should fall back to the textual label
*/
protected boolean writeDateCell(Cell cell,
AnalyticsAggregation aggregation,
String key,
ZoneId zoneId,
Map<String, CellStyle> styles) {
if (aggregation == null || StringUtils.isBlank(key)) {
return false;
}
String excelFormat = EXCEL_DATE_FORMATS.get(aggregation.getInterval());
if (excelFormat == null) {
return false;
}
long timestamp;
try {
timestamp = Long.parseLong(key);
} catch (NumberFormatException e) {
// Not an epoch-millis bucket key after all: the textual label is the
// only representation left
LOG.debug("Analytics export: bucket key '{}' is not a timestamp, exporting its label instead", key, e);
return false;
}
writeDateValue(cell, timestamp, zoneId, styles, excelFormat);
return true;
}

private void writeDateValue(Cell cell, long timestamp, ZoneId zoneId, Map<String, CellStyle> styles, String excelFormat) {
Workbook workbook = cell.getSheet().getWorkbook();
CellStyle style = styles.computeIfAbsent(excelFormat, format -> {
CellStyle createdStyle = workbook.createCellStyle();
createdStyle.setDataFormat(workbook.createDataFormat().getFormat(format));
return createdStyle;
});
// setCellValue(LocalDateTime) writes the wall-clock value as-is, unlike
// the Date overload which would re-read it through the server's default
// time zone
cell.setCellValue(LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp),
zoneId == null ? ZoneOffset.UTC : zoneId));
cell.setCellStyle(style);
}

enum SearchScope {
USER,
SPACE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@

import java.io.IOException;
import java.io.OutputStream;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.portlet.PortletException;
Expand All @@ -37,6 +40,8 @@

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
Expand All @@ -45,11 +50,14 @@

import io.meeds.analytics.model.StatisticData;
import io.meeds.analytics.model.StatisticFieldMapping;
import io.meeds.analytics.model.chart.ChartAggregationLabel;
import io.meeds.analytics.model.chart.ChartAggregationResult;
import io.meeds.analytics.model.chart.ChartAggregationValue;
import io.meeds.analytics.model.chart.ChartData;
import io.meeds.analytics.model.chart.ChartDataList;
import io.meeds.analytics.model.filter.AnalyticsFilter;
import io.meeds.analytics.model.filter.aggregation.AnalyticsAggregation;
import io.meeds.analytics.model.filter.aggregation.AnalyticsAggregationType;
import io.meeds.analytics.model.filter.search.AnalyticsFieldFilter;
import io.meeds.analytics.utils.AnalyticsUtils;

Expand Down Expand Up @@ -138,12 +146,12 @@ protected void exportExcel(ResourceRequest request, ResourceResponse response) t
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet(english ? "Chart" : "Graphique");
int columnsCount = pie ? writePieSheet(sheet, chartDataList, english)
: writeSeriesSheet(sheet, chartDataList, english, getXAxisFieldName(filter));
: writeSeriesSheet(sheet, chartDataList, english, getXAxisFieldName(filter), filter.zoneId());
for (int i = 0; i < columnsCount; i++) {
sheet.autoSizeColumn(i);
}

response.setContentType("application/vnd.ms-excel");
response.setContentType(XLSX_CONTENT_TYPE);
response.addProperty("Content-Disposition", "attachment; filename=" + buildFileName(filter) + ".xlsx");
try (OutputStream outputStream = response.getPortletOutputStream()) {
workbook.write(outputStream);
Expand All @@ -155,11 +163,20 @@ protected void exportExcel(ResourceRequest request, ResourceResponse response) t
* Writes one row per x-axis category (as displayed on the chart), one
* column per series, mirroring the data actually shown on a line/bar/area
* chart rather than the raw collected samples.
* <p>
* A category produced by a date histogram is written as a real date cell
* rather than as the label the chart draws, so the sheet can be sorted and
* filtered chronologically (see
* {@link AbstractAnalyticsPortlet#writeDateCell}).
*
* @return the number of columns written, for later auto-sizing
*/
private int writeSeriesSheet(Sheet sheet, ChartDataList chartDataList, boolean english, String xAxisFieldName) {
List<String> labels = chartDataList.getLabels();
private int writeSeriesSheet(Sheet sheet,
ChartDataList chartDataList,
boolean english,
String xAxisFieldName,
ZoneId zoneId) {
List<ChartAggregationLabel> aggregationLabels = new ArrayList<>(chartDataList.getAggregationLabels());
List<ChartData> charts = new ArrayList<>(chartDataList.getCharts());

String xAxisHeader = StringUtils.isBlank(xAxisFieldName) ? categoryLabel(english) : xAxisFieldName;
Expand All @@ -169,9 +186,10 @@ private int writeSeriesSheet(Sheet sheet, ChartDataList chartDataList, boolean e
headerRow.createCell(col + 1).setCellValue(seriesLabel(charts.get(col).getChartLabel(), english));
}

for (int rowIndex = 0; rowIndex < labels.size(); rowIndex++) {
Map<String, CellStyle> dateStyles = new HashMap<>();
for (int rowIndex = 0; rowIndex < aggregationLabels.size(); rowIndex++) {
Row row = sheet.createRow(rowIndex + 1);
row.createCell(0).setCellValue(labels.get(rowIndex));
writeCategoryCell(row.createCell(0), aggregationLabels.get(rowIndex), zoneId, dateStyles);
for (int col = 0; col < charts.size(); col++) {
List<String> values = charts.get(col).getValues();
row.createCell(col + 1).setCellValue(rowIndex < values.size() ? parseDouble(values.get(rowIndex)) : 0d);
Expand All @@ -180,6 +198,28 @@ private int writeSeriesSheet(Sheet sheet, ChartDataList chartDataList, boolean e
return charts.size() + 1;
}

/**
* Writes one x-axis category, as a date cell when it is a single date
* bucket and as its label otherwise. A category aggregated on several
* x-axis fields at once carries a composite label ("date - space"), which
* only a text cell can hold.
*/
void writeCategoryCell(Cell cell,
ChartAggregationLabel aggregationLabel,
ZoneId zoneId,
Map<String, CellStyle> dateStyles) {
List<ChartAggregationValue> aggregationValues = aggregationLabel.getAggregationValues();
if (aggregationValues != null && aggregationValues.size() == 1) {
ChartAggregationValue aggregationValue = aggregationValues.get(0);
if (aggregationValue.getAggregation() != null
&& aggregationValue.getAggregation().getType() == AnalyticsAggregationType.DATE
&& writeDateCell(cell, aggregationValue.getAggregation(), aggregationValue.getFieldValue(), zoneId, dateStyles)) {
return;
}
}
cell.setCellValue(aggregationLabel.getLabel());
}

/**
* Writes one row per pie segment (as displayed on the chart). When the
* chart is split into several pies (multiple charts field), an extra
Expand Down
Loading
Loading