mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
activeUsers (#4936)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
package stirling.software.SPDF.config;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import jakarta.servlet.Filter;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.ServletRequest;
|
||||||
|
import jakarta.servlet.ServletResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
|
||||||
|
* Only active when security is disabled (no-login mode).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class WAUTrackingFilter implements Filter {
|
||||||
|
|
||||||
|
private final WeeklyActiveUsersService wauService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||||
|
throws IOException, ServletException {
|
||||||
|
|
||||||
|
if (request instanceof HttpServletRequest httpRequest) {
|
||||||
|
// Extract browser ID from header
|
||||||
|
String browserId = httpRequest.getHeader("X-Browser-Id");
|
||||||
|
|
||||||
|
if (browserId != null && !browserId.trim().isEmpty()) {
|
||||||
|
// Record browser access
|
||||||
|
wauService.recordBrowserAccess(browserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue the filter chain
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
"tauri://localhost",
|
"tauri://localhost",
|
||||||
"http://tauri.localhost",
|
"http://tauri.localhost",
|
||||||
"https://tauri.localhost")
|
"https://tauri.localhost")
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||||
.allowedHeaders("*")
|
.allowedHeaders(
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Requested-With",
|
||||||
|
"Accept",
|
||||||
|
"Origin",
|
||||||
|
"X-API-KEY",
|
||||||
|
"X-CSRF-TOKEN",
|
||||||
|
"X-XSRF-TOKEN",
|
||||||
|
"X-Browser-Id")
|
||||||
|
.exposedHeaders(
|
||||||
|
"WWW-Authenticate",
|
||||||
|
"X-Total-Count",
|
||||||
|
"X-Page-Number",
|
||||||
|
"X-Page-Size",
|
||||||
|
"Content-Disposition",
|
||||||
|
"Content-Type")
|
||||||
.allowCredentials(true)
|
.allowCredentials(true)
|
||||||
.maxAge(3600);
|
.maxAge(3600);
|
||||||
} else if (hasConfiguredOrigins) {
|
} else if (hasConfiguredOrigins) {
|
||||||
@@ -73,7 +89,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
"Origin",
|
"Origin",
|
||||||
"X-API-KEY",
|
"X-API-KEY",
|
||||||
"X-CSRF-TOKEN",
|
"X-CSRF-TOKEN",
|
||||||
"X-XSRF-TOKEN")
|
"X-XSRF-TOKEN",
|
||||||
|
"X-Browser-Id")
|
||||||
.exposedHeaders(
|
.exposedHeaders(
|
||||||
"WWW-Authenticate",
|
"WWW-Authenticate",
|
||||||
"X-Total-Count",
|
"X-Total-Count",
|
||||||
@@ -98,7 +115,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
"Origin",
|
"Origin",
|
||||||
"X-API-KEY",
|
"X-API-KEY",
|
||||||
"X-CSRF-TOKEN",
|
"X-CSRF-TOKEN",
|
||||||
"X-XSRF-TOKEN")
|
"X-XSRF-TOKEN",
|
||||||
|
"X-Browser-Id")
|
||||||
.exposedHeaders(
|
.exposedHeaders(
|
||||||
"WWW-Authenticate",
|
"WWW-Authenticate",
|
||||||
"X-Total-Count",
|
"X-Total-Count",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
|
|
||||||
import stirling.software.SPDF.config.EndpointInspector;
|
import stirling.software.SPDF.config.EndpointInspector;
|
||||||
import stirling.software.SPDF.config.StartupApplicationListener;
|
import stirling.software.SPDF.config.StartupApplicationListener;
|
||||||
|
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||||
import stirling.software.common.annotations.api.InfoApi;
|
import stirling.software.common.annotations.api.InfoApi;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ public class MetricsController {
|
|||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
private final MeterRegistry meterRegistry;
|
private final MeterRegistry meterRegistry;
|
||||||
private final EndpointInspector endpointInspector;
|
private final EndpointInspector endpointInspector;
|
||||||
|
private final Optional<WeeklyActiveUsersService> wauService;
|
||||||
private boolean metricsEnabled;
|
private boolean metricsEnabled;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
@@ -352,6 +354,35 @@ public class MetricsController {
|
|||||||
return ResponseEntity.ok(formatDuration(uptime));
|
return ResponseEntity.ok(formatDuration(uptime));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/wau")
|
||||||
|
@Operation(
|
||||||
|
summary = "Weekly Active Users statistics",
|
||||||
|
description =
|
||||||
|
"Returns WAU (Weekly Active Users) count and total unique browsers. "
|
||||||
|
+ "Only available when security is disabled (no-login mode). "
|
||||||
|
+ "Tracks unique browsers via client-generated UUID in localStorage.")
|
||||||
|
public ResponseEntity<?> getWeeklyActiveUsers() {
|
||||||
|
if (!metricsEnabled) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if WAU service is available (only when security.enableLogin=false)
|
||||||
|
if (wauService.isEmpty()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body("WAU tracking is only available when security is disabled (no-login mode)");
|
||||||
|
}
|
||||||
|
|
||||||
|
WeeklyActiveUsersService service = wauService.get();
|
||||||
|
|
||||||
|
Map<String, Object> wauStats = new HashMap<>();
|
||||||
|
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
|
||||||
|
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
|
||||||
|
wauStats.put("daysOnline", service.getDaysOnline());
|
||||||
|
wauStats.put("trackingSince", service.getStartTime().toString());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(wauStats);
|
||||||
|
}
|
||||||
|
|
||||||
private String formatDuration(Duration duration) {
|
private String formatDuration(Duration duration) {
|
||||||
long days = duration.toDays();
|
long days = duration.toDays();
|
||||||
long hours = duration.toHoursPart();
|
long hours = duration.toHoursPart();
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package stirling.software.SPDF.service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for tracking Weekly Active Users (WAU) in no-login mode.
|
||||||
|
* Uses in-memory storage with automatic cleanup of old entries.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class WeeklyActiveUsersService {
|
||||||
|
|
||||||
|
// Map of browser ID -> last seen timestamp
|
||||||
|
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Track total unique browsers seen (overall)
|
||||||
|
private long totalUniqueBrowsers = 0;
|
||||||
|
|
||||||
|
// Application start time
|
||||||
|
private final Instant startTime = Instant.now();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a browser access with the current timestamp
|
||||||
|
* @param browserId Unique browser identifier from X-Browser-Id header
|
||||||
|
*/
|
||||||
|
public void recordBrowserAccess(String browserId) {
|
||||||
|
if (browserId == null || browserId.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
|
||||||
|
activeBrowsers.put(browserId, Instant.now());
|
||||||
|
|
||||||
|
if (isNewBrowser) {
|
||||||
|
totalUniqueBrowsers++;
|
||||||
|
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the count of unique browsers seen in the last 7 days
|
||||||
|
* @return Weekly Active Users count
|
||||||
|
*/
|
||||||
|
public long getWeeklyActiveUsers() {
|
||||||
|
cleanupOldEntries();
|
||||||
|
return activeBrowsers.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total count of unique browsers ever seen
|
||||||
|
* @return Total unique browsers count
|
||||||
|
*/
|
||||||
|
public long getTotalUniqueBrowsers() {
|
||||||
|
return totalUniqueBrowsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of days the service has been running
|
||||||
|
* @return Days online
|
||||||
|
*/
|
||||||
|
public long getDaysOnline() {
|
||||||
|
return ChronoUnit.DAYS.between(startTime, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the timestamp when tracking started
|
||||||
|
* @return Start time
|
||||||
|
*/
|
||||||
|
public Instant getStartTime() {
|
||||||
|
return startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes entries older than 7 days
|
||||||
|
*/
|
||||||
|
private void cleanupOldEntries() {
|
||||||
|
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||||
|
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual cleanup trigger (can be called by scheduled task if needed)
|
||||||
|
*/
|
||||||
|
public void performCleanup() {
|
||||||
|
int sizeBefore = activeBrowsers.size();
|
||||||
|
cleanupOldEntries();
|
||||||
|
int sizeAfter = activeBrowsers.size();
|
||||||
|
|
||||||
|
if (sizeBefore != sizeAfter) {
|
||||||
|
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
|
import { getBrowserId } from '@app/utils/browserIdentifier';
|
||||||
|
|
||||||
export function setupApiInterceptors(_client: AxiosInstance): void {
|
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||||
// Core version: no interceptors to add
|
// Add browser ID header for WAU tracking
|
||||||
|
client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const browserId = getBrowserId();
|
||||||
|
config.headers['X-Browser-Id'] = browserId;
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Browser identifier utility for anonymous usage tracking
|
||||||
|
* Generates and persists a unique UUID in localStorage for WAU tracking
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BROWSER_ID_KEY = 'stirling_browser_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets or creates a unique browser identifier
|
||||||
|
* Used for Weekly Active Users (WAU) tracking in no-login mode
|
||||||
|
*/
|
||||||
|
export function getBrowserId(): string {
|
||||||
|
try {
|
||||||
|
// Try to get existing ID from localStorage
|
||||||
|
let browserId = localStorage.getItem(BROWSER_ID_KEY);
|
||||||
|
|
||||||
|
if (!browserId) {
|
||||||
|
// Generate new UUID v4
|
||||||
|
browserId = generateUUID();
|
||||||
|
localStorage.setItem(BROWSER_ID_KEY, browserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return browserId;
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to session-based ID if localStorage is unavailable
|
||||||
|
console.warn('localStorage unavailable, using session-based ID', error);
|
||||||
|
return `session_${generateUUID()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a UUID v4
|
||||||
|
*/
|
||||||
|
function generateUUID(): string {
|
||||||
|
// Use crypto.randomUUID if available (modern browsers)
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to manual UUID generation
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user