Skip to content
Draft
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
13 changes: 4 additions & 9 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.3</version>
<version>3.4.5</version>
<relativePath/>
</parent>

<properties>
<java.version>17</java.version>
<javafx.version>17.0.10</javafx.version>
<tomcat.version>10.1.54</tomcat.version>
<spring-framework.version>6.2.11</spring-framework.version>
<spring-security.version>6.5.9</spring-security.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

Expand Down Expand Up @@ -91,7 +94,6 @@
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>

<dependency>
Expand All @@ -114,7 +116,6 @@
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>10.1.30</version>
</dependency>

<dependency>
Expand All @@ -132,7 +133,6 @@
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>6.1.3</version>
</dependency>

<dependency>
Expand All @@ -148,13 +148,11 @@
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>6.3.0</version>
</dependency>

<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>11.0.0-M26</version>
</dependency>

<dependency>
Expand All @@ -166,19 +164,16 @@
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>6.1.12</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>3.3.3</version>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<version>6.3.0</version>
</dependency>

<dependency>
Expand Down
14 changes: 13 additions & 1 deletion src/main/java/org/taniwha/controller/AnalyticsController.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.web.bind.annotation.*;
import org.taniwha.dto.*;
import org.taniwha.service.jobs.AnalyticsProcessingJobs;
import org.taniwha.service.AnalyticsAuditService;
import org.taniwha.service.AnalyticsService;

import java.util.Collections;
Expand All @@ -21,16 +22,21 @@ public class AnalyticsController {

private final AnalyticsService analyticsService;
private final AnalyticsProcessingJobs jobs;
private final AnalyticsAuditService auditService;

public AnalyticsController(AnalyticsService analyticsService, AnalyticsProcessingJobs jobs) {
public AnalyticsController(AnalyticsService analyticsService,
AnalyticsProcessingJobs jobs,
AnalyticsAuditService auditService) {
this.analyticsService = analyticsService;
this.jobs = jobs;
this.auditService = auditService;
}

@PostMapping("/processList")
public ResponseEntity<Object> processList(@RequestBody FileNamesDTO dto) {
try {
List<String> fileNames = dto.getFileNames();
auditService.logRequest("PROCESS", fileNames);
boolean huge = analyticsService.isAnyHugeForDiscovery(fileNames);

if (!huge) {
Expand Down Expand Up @@ -100,6 +106,7 @@ public ResponseEntity<AnalyticsResponseDTO> recalculateFeatureList(
@RequestParam("featureType") String featureType
) {
logger.debug("File reprocessing request: {} as type {} for file: {}", featureName, featureType, fileName);
auditService.logRequest("REPROCESS", fileName);
if (!featureType.equalsIgnoreCase("continuous") && !featureType.equalsIgnoreCase("categorical")) {
logger.warn("Invalid feature type provided: {}", featureType);
return ResponseEntity.badRequest().body(new AnalyticsResponseDTO("Invalid feature type"));
Expand All @@ -123,6 +130,11 @@ public ResponseEntity<AnalyticsResponseDTO> recalculateFeatureList(
@PostMapping("/filterByNameList")
public ResponseEntity<List<AnalyticsResponseDTO>> filterByNameList(@RequestBody MultiFileFilterRequest payload) {
logger.debug("Received multiple-file filter request with {} entries", payload.getMultipleFileFilters().size());
List<String> fileNames = payload.getMultipleFileFilters().stream()
.map(ff -> ff.getFileName()).toList();
boolean anyFilters = payload.getMultipleFileFilters().stream()
.anyMatch(ff -> ff.getFilters() != null && !ff.getFilters().isEmpty());
auditService.logRequest("FILTER", fileNames, anyFilters);
try {
List<AnalyticsResponseDTO> filteredList = analyticsService.filterMultipleFilesByName(payload.getMultipleFileFilters());
return ResponseEntity.ok(filteredList);
Expand Down
127 changes: 127 additions & 0 deletions src/main/java/org/taniwha/service/AnalyticsAuditService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package org.taniwha.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Collection;
import java.util.Map;

/**
* Records structured audit entries for every analytics and filtering operation.
*
* <p>Entries are written to the dedicated {@code AUDIT} SLF4J logger, which is
* routed to its own rolling file (see {@code logback.xml}). Each entry is a
* single line in key=value format so it can be ingested by log-aggregation
* tools without additional parsing configuration.
*
* <p>Fields written per entry:
* <ul>
* <li>{@code ts} – ISO-8601 UTC timestamp</li>
* <li>{@code op} – operation name (e.g. {@code PROCESS}, {@code FILTER})</li>
* <li>{@code file} – dataset file name (or comma-separated list)</li>
* <li>{@code filters} – {@code true}/{@code false} whether filters were applied</li>
* <li>{@code records} – number of records in the result (request entries omit this)</li>
* <li>{@code suppressed} – features suppressed by disclosure controls</li>
* <li>{@code principal} – authenticated user name, or {@code anonymous}</li>
* </ul>
*/
@Service
public class AnalyticsAuditService {

private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT");

// -------------------------------------------------------------------------
// Request-level audit entries (logged in the controller)
// -------------------------------------------------------------------------

/**
* Logs the start of a multi-file analytics request (no filter conditions).
*
* @param operation human-readable operation label (e.g. {@code "PROCESS"})
* @param fileNames the file names requested
*/
public void logRequest(String operation, Collection<String> fileNames) {
auditLogger.info("ts={} op={} files=[{}] principal={}",
Instant.now(), operation, String.join(",", fileNames), resolvePrincipal());
}

/**
* Logs a filter request that carries per-file filter conditions.
*
* @param operation human-readable operation label
* @param fileNames the file names requested
* @param hasFilters whether any filter conditions were supplied
*/
public void logRequest(String operation, Collection<String> fileNames, boolean hasFilters) {
auditLogger.info("ts={} op={} files=[{}] filters={} principal={}",
Instant.now(), operation, String.join(",", fileNames), hasFilters, resolvePrincipal());
}

/**
* Logs a single-file request without filter conditions.
*
* @param operation human-readable operation label
* @param fileName the file name requested
*/
public void logRequest(String operation, String fileName) {
auditLogger.info("ts={} op={} files=[{}] principal={}",
Instant.now(), operation, fileName, resolvePrincipal());
}

/**
* Logs a single-file request with named filter conditions.
*
* @param operation human-readable operation label
* @param fileName the file name requested
* @param filters map of filter conditions (keys are logged; values are not)
*/
public void logRequest(String operation, String fileName, Map<String, Object> filters) {
boolean hasFilters = filters != null && !filters.isEmpty();
auditLogger.info("ts={} op={} files=[{}] filters={} filterKeys=[{}] principal={}",
Instant.now(), operation, fileName, hasFilters,
hasFilters ? String.join(",", filters.keySet()) : "",
resolvePrincipal());
}

// -------------------------------------------------------------------------
// Response-level audit entries (logged in the service after processing)
// -------------------------------------------------------------------------

/**
* Logs the outcome of processing a single file.
*
* @param operation human-readable operation label
* @param fileName the processed file name
* @param recordCount number of records in the result
* @param suppressedFeatures features suppressed by disclosure controls
*/
public void logResponse(String operation, String fileName, long recordCount, int suppressedFeatures) {
auditLogger.info("ts={} op={} file={} records={} suppressed={} principal={}",
Instant.now(), operation, fileName, recordCount, suppressedFeatures, resolvePrincipal());
}

// -------------------------------------------------------------------------
// Helper
// -------------------------------------------------------------------------

/**
* Returns the name of the currently authenticated principal, or
* {@code "anonymous"} if no authentication is available (e.g. in an
* async thread where the {@link SecurityContextHolder} is not propagated).
*/
private String resolvePrincipal() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) {
return auth.getName();
}
} catch (Exception ignored) {
// Defensive: SecurityContextHolder may throw in certain async contexts
}
return "anonymous";
}
}
19 changes: 17 additions & 2 deletions src/main/java/org/taniwha/service/AnalyticsService.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,22 @@ public class AnalyticsService {
private final DataProcessingService dataProcessingService;
private final FileService fileService;
private final AnalyticsProcessingJobs jobs;
private final DisclosureControlService disclosureControl;
private final AnalyticsAuditService auditService;
private final ExecutorService discoveryJobExecutor;

private static final String SUCCESS_MSG = "Data processed successfully";

public AnalyticsService(DataProcessingService dataProcessingService,
FileService fileService,
AnalyticsProcessingJobs jobs) {
AnalyticsProcessingJobs jobs,
DisclosureControlService disclosureControl,
AnalyticsAuditService auditService) {
this.dataProcessingService = dataProcessingService;
this.fileService = fileService;
this.jobs = jobs;
this.disclosureControl = disclosureControl;
this.auditService = auditService;
// Use a thread factory with meaningful thread names for debugging
AtomicLong threadCounter = new AtomicLong(0);
ThreadFactory threadFactory = r -> {
Expand Down Expand Up @@ -349,6 +355,9 @@ private AnalyticsResponseDTO processSingleFileOnDiskWithProgress(String jobId,
response.setSpearmanCorrelations(calculator.calculateSpearmanCorrelations(continuousData));
response.setChiSquareTest(calculator.calculateChiSquaredTest(categoricalData, comboCounts));

int suppressed = disclosureControl.apply(response, totalRows);
auditService.logResponse("PROCESS", filename, totalRows, suppressed);

response.setMessage("File processed successfully: " + filename);
return response;

Expand Down Expand Up @@ -415,6 +424,9 @@ public CompletableFuture<AnalyticsResponseDTO> processSingleFileOnDisk(String fi
response.setSpearmanCorrelations(calculator.calculateSpearmanCorrelations(continuousData));
response.setChiSquareTest(calculator.calculateChiSquaredTest(categoricalData, comboCounts));

int suppressed = disclosureControl.apply(response, totalRows);
auditService.logResponse("PROCESS", filename, totalRows, suppressed);

response.setMessage("File processed successfully: " + filename);
} catch (Exception e) {
logger.error("Error processing file {}", filename, e);
Expand Down Expand Up @@ -460,6 +472,8 @@ public CompletableFuture<AnalyticsResponseDTO> recalculateFeatureAsTypeFromDisk(
return CompletableFuture.completedFuture(response);
}
processData(records, Optional.of(featureName), Optional.of(featureType), response, categoryCombinationCounts);
int suppressed = disclosureControl.apply(response, records.size());
auditService.logResponse("REPROCESS", fileName, records.size(), suppressed);
response.setMessage(SUCCESS_MSG);
} catch (Exception e) {
String errMsg = e.getMessage() != null ? e.getMessage() : "Unknown error";
Expand Down Expand Up @@ -763,6 +777,8 @@ public CompletableFuture<AnalyticsResponseDTO> filterDataByName(String fileName,
}

processData(records, Optional.empty(), Optional.empty(), response, categoryCombinationCounts);
int suppressed = disclosureControl.apply(response, records.size());
auditService.logResponse("FILTER", fileName, records.size(), suppressed);
response.setMessage(SUCCESS_MSG);
} catch (Exception e) {
logger.error("Error filtering file by name {}", fileName, e);
Expand Down Expand Up @@ -830,7 +846,6 @@ private void processData(List<Map<String, String>> records,
response.setCovariances(calculator.calculateCovariances(continuousData));
response.setPearsonCorrelations(calculator.calculatePearsonCorrelations(continuousData));
response.setSpearmanCorrelations(calculator.calculateSpearmanCorrelations(continuousData));
response.setSpearmanCorrelations(calculator.calculateSpearmanCorrelations(continuousData));
response.setChiSquareTest(calculator.calculateChiSquaredTest(categoricalData, categoryCombinationCounts));

response.setMessage(SUCCESS_MSG);
Expand Down
Loading