Skip to content

Commit a3fe15b

Browse files
authored
Add metrics for numerical count of total PDFs (Stirling-Tools#6737)
1 parent 1a770af commit a3fe15b

20 files changed

Lines changed: 475 additions & 8 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package stirling.software.SPDF.config;
2+
3+
import java.util.List;
4+
5+
import org.springframework.stereotype.Component;
6+
import org.springframework.web.multipart.MultipartFile;
7+
import org.springframework.web.multipart.MultipartHttpServletRequest;
8+
import org.springframework.web.servlet.HandlerInterceptor;
9+
10+
import jakarta.servlet.http.HttpServletRequest;
11+
import jakarta.servlet.http.HttpServletResponse;
12+
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
16+
import stirling.software.SPDF.service.PdfMetricsService;
17+
18+
@Component
19+
@Slf4j
20+
@RequiredArgsConstructor
21+
public class PdfMetricsInterceptor implements HandlerInterceptor {
22+
23+
private final PdfMetricsService pdfMetricsService;
24+
25+
@Override
26+
public void afterCompletion(
27+
HttpServletRequest request,
28+
HttpServletResponse response,
29+
Object handler,
30+
Exception ex) {
31+
try {
32+
if (!pdfMetricsService.isEnabled()) {
33+
return;
34+
}
35+
if (!"POST".equalsIgnoreCase(request.getMethod()) || response.getStatus() >= 400) {
36+
return;
37+
}
38+
String path = request.getServletPath();
39+
if (path == null || path.isBlank()) {
40+
path = request.getRequestURI();
41+
}
42+
if (path == null || !path.contains("/api/v1/")) {
43+
return;
44+
}
45+
if (!(request instanceof MultipartHttpServletRequest multipart)) {
46+
return;
47+
}
48+
if (isFromEditor(request)) {
49+
return;
50+
}
51+
52+
int fileCount = 0;
53+
for (List<MultipartFile> bucket : multipart.getMultiFileMap().values()) {
54+
fileCount += bucket.size();
55+
}
56+
if (fileCount == 0) {
57+
return;
58+
}
59+
60+
pdfMetricsService.recordOperation(fileCount);
61+
} catch (Exception e) {
62+
log.debug("Failed to record PDF metrics", e);
63+
}
64+
}
65+
66+
// Editor traffic carries X-Browser-Id, or (if a proxy strips it) a logged-in user's JWT.
67+
// JWTs start "eyJ" and have two dots; API keys do not, so they still count as API.
68+
private boolean isFromEditor(HttpServletRequest request) {
69+
String browserId = request.getHeader("X-Browser-Id");
70+
if (browserId != null && !browserId.isBlank()) {
71+
return true;
72+
}
73+
String auth = request.getHeader("Authorization");
74+
if (auth == null || !auth.regionMatches(true, 0, "Bearer ", 0, 7)) {
75+
return false;
76+
}
77+
String token = auth.substring(7).trim();
78+
return token.startsWith("eyJ") && token.chars().filter(c -> c == '.').count() == 2;
79+
}
80+
}

app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
public class WebMvcConfig implements WebMvcConfigurer {
2525

2626
private final EndpointInterceptor endpointInterceptor;
27+
private final PdfMetricsInterceptor pdfMetricsInterceptor;
2728
private final ApplicationProperties applicationProperties;
2829

2930
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
@@ -35,6 +36,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
3536
@Override
3637
public void addInterceptors(InterceptorRegistry registry) {
3738
registry.addInterceptor(endpointInterceptor);
39+
registry.addInterceptor(pdfMetricsInterceptor);
3840
}
3941

4042
@Override
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package stirling.software.SPDF.service;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
import java.util.concurrent.atomic.AtomicLong;
6+
7+
import org.springframework.scheduling.annotation.Scheduled;
8+
import org.springframework.stereotype.Service;
9+
10+
import stirling.software.common.model.ApplicationProperties;
11+
import stirling.software.common.service.PostHogService;
12+
13+
@Service
14+
public class PdfMetricsService {
15+
16+
private final PostHogService postHogService;
17+
private final ApplicationProperties applicationProperties;
18+
19+
private final AtomicLong operations = new AtomicLong();
20+
private final AtomicLong pdfs = new AtomicLong();
21+
private long lastOperations;
22+
private long lastPdfs;
23+
24+
public PdfMetricsService(
25+
PostHogService postHogService, ApplicationProperties applicationProperties) {
26+
this.postHogService = postHogService;
27+
this.applicationProperties = applicationProperties;
28+
}
29+
30+
public boolean isEnabled() {
31+
return applicationProperties.getSystem().isPosthogEnabled();
32+
}
33+
34+
public void recordOperation(int pdfCount) {
35+
if (!isEnabled()) {
36+
return;
37+
}
38+
operations.incrementAndGet();
39+
if (pdfCount > 0) {
40+
pdfs.addAndGet(pdfCount);
41+
}
42+
}
43+
44+
@Scheduled(fixedRate = 7200000)
45+
public void flushMetrics() {
46+
if (!isEnabled()) {
47+
return;
48+
}
49+
long curOps = operations.get();
50+
long curPdfs = pdfs.get();
51+
long opsDelta = curOps - lastOperations;
52+
long pdfsDelta = curPdfs - lastPdfs;
53+
if (opsDelta <= 0 && pdfsDelta <= 0) {
54+
return;
55+
}
56+
57+
Map<String, Object> props = new HashMap<>();
58+
props.put("source", "api");
59+
props.put("operations", opsDelta);
60+
props.put("pdfs", pdfsDelta);
61+
postHogService.captureEvent("pdf_operation_metrics", props);
62+
63+
lastOperations = curOps;
64+
lastPdfs = curPdfs;
65+
}
66+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package stirling.software.SPDF.config;
2+
3+
import static org.mockito.ArgumentMatchers.anyInt;
4+
import static org.mockito.Mockito.mock;
5+
import static org.mockito.Mockito.never;
6+
import static org.mockito.Mockito.verify;
7+
import static org.mockito.Mockito.when;
8+
9+
import org.junit.jupiter.api.BeforeEach;
10+
import org.junit.jupiter.api.Test;
11+
import org.springframework.util.LinkedMultiValueMap;
12+
import org.springframework.util.MultiValueMap;
13+
import org.springframework.web.multipart.MultipartFile;
14+
import org.springframework.web.multipart.MultipartHttpServletRequest;
15+
16+
import jakarta.servlet.http.HttpServletResponse;
17+
18+
import stirling.software.SPDF.service.PdfMetricsService;
19+
20+
class PdfMetricsInterceptorTest {
21+
22+
private PdfMetricsService service;
23+
private PdfMetricsInterceptor interceptor;
24+
25+
@BeforeEach
26+
void setUp() {
27+
service = mock(PdfMetricsService.class);
28+
when(service.isEnabled()).thenReturn(true);
29+
interceptor = new PdfMetricsInterceptor(service);
30+
}
31+
32+
private MultipartHttpServletRequest editRequest(int fileParts, String... headers) {
33+
MultipartHttpServletRequest request = mock(MultipartHttpServletRequest.class);
34+
when(request.getMethod()).thenReturn("POST");
35+
when(request.getServletPath()).thenReturn("/api/v1/general/rotate-pdf");
36+
for (int i = 0; i + 1 < headers.length; i += 2) {
37+
when(request.getHeader(headers[i])).thenReturn(headers[i + 1]);
38+
}
39+
MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap<>();
40+
for (int i = 0; i < fileParts; i++) {
41+
files.add("fileInput", mock(MultipartFile.class));
42+
}
43+
when(request.getMultiFileMap()).thenReturn(files);
44+
return request;
45+
}
46+
47+
private HttpServletResponse response(int status, String contentType) {
48+
HttpServletResponse response = mock(HttpServletResponse.class);
49+
when(response.getStatus()).thenReturn(status);
50+
when(response.getContentType()).thenReturn(contentType);
51+
return response;
52+
}
53+
54+
@Test
55+
void apiRequestIsCounted() {
56+
interceptor.afterCompletion(editRequest(1), response(200, "application/pdf"), null, null);
57+
verify(service).recordOperation(1);
58+
}
59+
60+
@Test
61+
void countsEveryFilePartUnderOneFieldName() {
62+
interceptor.afterCompletion(editRequest(3), response(200, "application/pdf"), null, null);
63+
verify(service).recordOperation(3);
64+
}
65+
66+
@Test
67+
void countsRegardlessOfResponseType() {
68+
interceptor.afterCompletion(editRequest(1), response(200, "application/json"), null, null);
69+
verify(service).recordOperation(1);
70+
}
71+
72+
@Test
73+
void editorRequestWithBrowserIdIsNotCounted() {
74+
interceptor.afterCompletion(
75+
editRequest(1, "X-Browser-Id", "abc-123"),
76+
response(200, "application/pdf"),
77+
null,
78+
null);
79+
verify(service, never()).recordOperation(anyInt());
80+
}
81+
82+
@Test
83+
void editorJwtWithoutBrowserIdIsNotCounted() {
84+
interceptor.afterCompletion(
85+
editRequest(1, "Authorization", "Bearer eyJhbG.eyJzdWI.sig"),
86+
response(200, "application/pdf"),
87+
null,
88+
null);
89+
verify(service, never()).recordOperation(anyInt());
90+
}
91+
92+
@Test
93+
void bearerApiKeyIsCounted() {
94+
interceptor.afterCompletion(
95+
editRequest(1, "Authorization", "Bearer sk-not-a-jwt-key"),
96+
response(200, "application/pdf"),
97+
null,
98+
null);
99+
verify(service).recordOperation(1);
100+
}
101+
102+
@Test
103+
void errorResponseIsNotCounted() {
104+
interceptor.afterCompletion(editRequest(1), response(500, "application/pdf"), null, null);
105+
verify(service, never()).recordOperation(anyInt());
106+
}
107+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package stirling.software.SPDF.service;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.mockito.ArgumentMatchers.anyMap;
5+
import static org.mockito.ArgumentMatchers.eq;
6+
import static org.mockito.Mockito.mock;
7+
import static org.mockito.Mockito.never;
8+
import static org.mockito.Mockito.reset;
9+
import static org.mockito.Mockito.verify;
10+
11+
import java.util.Map;
12+
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.Test;
15+
import org.mockito.ArgumentCaptor;
16+
17+
import stirling.software.common.model.ApplicationProperties;
18+
import stirling.software.common.service.PostHogService;
19+
20+
class PdfMetricsServiceTest {
21+
22+
private PostHogService postHogService;
23+
private ApplicationProperties applicationProperties;
24+
private PdfMetricsService service;
25+
26+
@BeforeEach
27+
void setUp() {
28+
postHogService = mock(PostHogService.class);
29+
applicationProperties = new ApplicationProperties();
30+
applicationProperties.getSystem().setEnableAnalytics(true);
31+
service = new PdfMetricsService(postHogService, applicationProperties);
32+
}
33+
34+
@Test
35+
void flushesOperationAndPdfCounts() {
36+
service.recordOperation(1);
37+
service.recordOperation(2);
38+
39+
service.flushMetrics();
40+
41+
Map<String, Object> event = captureEvent();
42+
assertEquals("api", event.get("source"));
43+
assertEquals(2L, event.get("operations"));
44+
assertEquals(3L, event.get("pdfs"));
45+
}
46+
47+
@Test
48+
void sendsOnlyDeltasBetweenFlushes() {
49+
service.recordOperation(1);
50+
service.flushMetrics();
51+
reset(postHogService);
52+
53+
service.flushMetrics();
54+
verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap());
55+
56+
service.recordOperation(2);
57+
service.flushMetrics();
58+
59+
Map<String, Object> event = captureEvent();
60+
assertEquals(1L, event.get("operations"));
61+
assertEquals(2L, event.get("pdfs"));
62+
}
63+
64+
@Test
65+
void doesNothingWhenAnalyticsDisabled() {
66+
applicationProperties.getSystem().setEnableAnalytics(false);
67+
68+
service.recordOperation(1);
69+
service.flushMetrics();
70+
71+
verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap());
72+
}
73+
74+
private Map<String, Object> captureEvent() {
75+
ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
76+
verify(postHogService).captureEvent(eq("pdf_operation_metrics"), captor.capture());
77+
return captor.getValue();
78+
}
79+
}

app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,8 @@ public CorsConfigurationSource corsConfigurationSource() {
202202
"Origin",
203203
"X-API-KEY",
204204
"X-CSRF-TOKEN",
205-
"X-XSRF-TOKEN"));
205+
"X-XSRF-TOKEN",
206+
"X-Browser-Id"));
206207

207208
cfg.setExposedHeaders(
208209
List.of(

app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,8 @@ CorsConfigurationSource corsConfigurationSource() {
296296
"X-Requested-With",
297297
"Accept",
298298
"Origin",
299-
"X-API-KEY"));
299+
"X-API-KEY",
300+
"X-Browser-Id"));
300301
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
301302
cfg.setAllowCredentials(true);
302303
cfg.setMaxAge(3600L);

frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ export const usePageEditorExport = ({
306306

307307
const newStirlingFiles = await actions.addFiles(renamedFiles, {
308308
selectFiles: true,
309+
skipUploadTracking: true,
309310
});
310311
if (newStirlingFiles.length > 0) {
311312
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));

frontend/editor/src/core/components/shared/signing/SignPopout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,7 @@ const SignPopout = ({
669669
const signedFile = new File([response.data], filename, {
670670
type: "application/pdf",
671671
});
672-
await fileActions.addFiles([signedFile]);
672+
await fileActions.addFiles([signedFile], { skipUploadTracking: true });
673673
alert({
674674
alertType: "success",
675675
title: t("success"),
@@ -700,7 +700,7 @@ const SignPopout = ({
700700
const signedFile = new File([response.data], filename, {
701701
type: "application/pdf",
702702
});
703-
await fileActions.addFiles([signedFile]);
703+
await fileActions.addFiles([signedFile], { skipUploadTracking: true });
704704
alert({
705705
alertType: "success",
706706
title: t("success"),

0 commit comments

Comments
 (0)