Add metrics for numerical count of total PDFs (#6737)

This commit is contained in:
Anthony Stirling
2026-06-19 18:57:03 +01:00
committed by GitHub
parent 1a770af47c
commit a3fe15bfd0
20 changed files with 475 additions and 8 deletions
@@ -0,0 +1,80 @@
package stirling.software.SPDF.config;
import java.util.List;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.PdfMetricsService;
@Component
@Slf4j
@RequiredArgsConstructor
public class PdfMetricsInterceptor implements HandlerInterceptor {
private final PdfMetricsService pdfMetricsService;
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
try {
if (!pdfMetricsService.isEnabled()) {
return;
}
if (!"POST".equalsIgnoreCase(request.getMethod()) || response.getStatus() >= 400) {
return;
}
String path = request.getServletPath();
if (path == null || path.isBlank()) {
path = request.getRequestURI();
}
if (path == null || !path.contains("/api/v1/")) {
return;
}
if (!(request instanceof MultipartHttpServletRequest multipart)) {
return;
}
if (isFromEditor(request)) {
return;
}
int fileCount = 0;
for (List<MultipartFile> bucket : multipart.getMultiFileMap().values()) {
fileCount += bucket.size();
}
if (fileCount == 0) {
return;
}
pdfMetricsService.recordOperation(fileCount);
} catch (Exception e) {
log.debug("Failed to record PDF metrics", e);
}
}
// Editor traffic carries X-Browser-Id, or (if a proxy strips it) a logged-in user's JWT.
// JWTs start "eyJ" and have two dots; API keys do not, so they still count as API.
private boolean isFromEditor(HttpServletRequest request) {
String browserId = request.getHeader("X-Browser-Id");
if (browserId != null && !browserId.isBlank()) {
return true;
}
String auth = request.getHeader("Authorization");
if (auth == null || !auth.regionMatches(true, 0, "Bearer ", 0, 7)) {
return false;
}
String token = auth.substring(7).trim();
return token.startsWith("eyJ") && token.chars().filter(c -> c == '.').count() == 2;
}
}
@@ -24,6 +24,7 @@ import stirling.software.common.model.ApplicationProperties;
public class WebMvcConfig implements WebMvcConfigurer {
private final EndpointInterceptor endpointInterceptor;
private final PdfMetricsInterceptor pdfMetricsInterceptor;
private final ApplicationProperties applicationProperties;
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
@@ -35,6 +36,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(endpointInterceptor);
registry.addInterceptor(pdfMetricsInterceptor);
}
@Override
@@ -0,0 +1,66 @@
package stirling.software.SPDF.service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.PostHogService;
@Service
public class PdfMetricsService {
private final PostHogService postHogService;
private final ApplicationProperties applicationProperties;
private final AtomicLong operations = new AtomicLong();
private final AtomicLong pdfs = new AtomicLong();
private long lastOperations;
private long lastPdfs;
public PdfMetricsService(
PostHogService postHogService, ApplicationProperties applicationProperties) {
this.postHogService = postHogService;
this.applicationProperties = applicationProperties;
}
public boolean isEnabled() {
return applicationProperties.getSystem().isPosthogEnabled();
}
public void recordOperation(int pdfCount) {
if (!isEnabled()) {
return;
}
operations.incrementAndGet();
if (pdfCount > 0) {
pdfs.addAndGet(pdfCount);
}
}
@Scheduled(fixedRate = 7200000)
public void flushMetrics() {
if (!isEnabled()) {
return;
}
long curOps = operations.get();
long curPdfs = pdfs.get();
long opsDelta = curOps - lastOperations;
long pdfsDelta = curPdfs - lastPdfs;
if (opsDelta <= 0 && pdfsDelta <= 0) {
return;
}
Map<String, Object> props = new HashMap<>();
props.put("source", "api");
props.put("operations", opsDelta);
props.put("pdfs", pdfsDelta);
postHogService.captureEvent("pdf_operation_metrics", props);
lastOperations = curOps;
lastPdfs = curPdfs;
}
}
@@ -0,0 +1,107 @@
package stirling.software.SPDF.config;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.SPDF.service.PdfMetricsService;
class PdfMetricsInterceptorTest {
private PdfMetricsService service;
private PdfMetricsInterceptor interceptor;
@BeforeEach
void setUp() {
service = mock(PdfMetricsService.class);
when(service.isEnabled()).thenReturn(true);
interceptor = new PdfMetricsInterceptor(service);
}
private MultipartHttpServletRequest editRequest(int fileParts, String... headers) {
MultipartHttpServletRequest request = mock(MultipartHttpServletRequest.class);
when(request.getMethod()).thenReturn("POST");
when(request.getServletPath()).thenReturn("/api/v1/general/rotate-pdf");
for (int i = 0; i + 1 < headers.length; i += 2) {
when(request.getHeader(headers[i])).thenReturn(headers[i + 1]);
}
MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap<>();
for (int i = 0; i < fileParts; i++) {
files.add("fileInput", mock(MultipartFile.class));
}
when(request.getMultiFileMap()).thenReturn(files);
return request;
}
private HttpServletResponse response(int status, String contentType) {
HttpServletResponse response = mock(HttpServletResponse.class);
when(response.getStatus()).thenReturn(status);
when(response.getContentType()).thenReturn(contentType);
return response;
}
@Test
void apiRequestIsCounted() {
interceptor.afterCompletion(editRequest(1), response(200, "application/pdf"), null, null);
verify(service).recordOperation(1);
}
@Test
void countsEveryFilePartUnderOneFieldName() {
interceptor.afterCompletion(editRequest(3), response(200, "application/pdf"), null, null);
verify(service).recordOperation(3);
}
@Test
void countsRegardlessOfResponseType() {
interceptor.afterCompletion(editRequest(1), response(200, "application/json"), null, null);
verify(service).recordOperation(1);
}
@Test
void editorRequestWithBrowserIdIsNotCounted() {
interceptor.afterCompletion(
editRequest(1, "X-Browser-Id", "abc-123"),
response(200, "application/pdf"),
null,
null);
verify(service, never()).recordOperation(anyInt());
}
@Test
void editorJwtWithoutBrowserIdIsNotCounted() {
interceptor.afterCompletion(
editRequest(1, "Authorization", "Bearer eyJhbG.eyJzdWI.sig"),
response(200, "application/pdf"),
null,
null);
verify(service, never()).recordOperation(anyInt());
}
@Test
void bearerApiKeyIsCounted() {
interceptor.afterCompletion(
editRequest(1, "Authorization", "Bearer sk-not-a-jwt-key"),
response(200, "application/pdf"),
null,
null);
verify(service).recordOperation(1);
}
@Test
void errorResponseIsNotCounted() {
interceptor.afterCompletion(editRequest(1), response(500, "application/pdf"), null, null);
verify(service, never()).recordOperation(anyInt());
}
}
@@ -0,0 +1,79 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.PostHogService;
class PdfMetricsServiceTest {
private PostHogService postHogService;
private ApplicationProperties applicationProperties;
private PdfMetricsService service;
@BeforeEach
void setUp() {
postHogService = mock(PostHogService.class);
applicationProperties = new ApplicationProperties();
applicationProperties.getSystem().setEnableAnalytics(true);
service = new PdfMetricsService(postHogService, applicationProperties);
}
@Test
void flushesOperationAndPdfCounts() {
service.recordOperation(1);
service.recordOperation(2);
service.flushMetrics();
Map<String, Object> event = captureEvent();
assertEquals("api", event.get("source"));
assertEquals(2L, event.get("operations"));
assertEquals(3L, event.get("pdfs"));
}
@Test
void sendsOnlyDeltasBetweenFlushes() {
service.recordOperation(1);
service.flushMetrics();
reset(postHogService);
service.flushMetrics();
verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap());
service.recordOperation(2);
service.flushMetrics();
Map<String, Object> event = captureEvent();
assertEquals(1L, event.get("operations"));
assertEquals(2L, event.get("pdfs"));
}
@Test
void doesNothingWhenAnalyticsDisabled() {
applicationProperties.getSystem().setEnableAnalytics(false);
service.recordOperation(1);
service.flushMetrics();
verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap());
}
private Map<String, Object> captureEvent() {
ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
verify(postHogService).captureEvent(eq("pdf_operation_metrics"), captor.capture());
return captor.getValue();
}
}
@@ -202,7 +202,8 @@ public class SecurityConfiguration {
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
"X-XSRF-TOKEN",
"X-Browser-Id"));
cfg.setExposedHeaders(
List.of(
@@ -296,7 +296,8 @@ public class SupabaseSecurityConfig {
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY"));
"X-API-KEY",
"X-Browser-Id"));
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
@@ -306,6 +306,7 @@ export const usePageEditorExport = ({
const newStirlingFiles = await actions.addFiles(renamedFiles, {
selectFiles: true,
skipUploadTracking: true,
});
if (newStirlingFiles.length > 0) {
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
@@ -669,7 +669,7 @@ const SignPopout = ({
const signedFile = new File([response.data], filename, {
type: "application/pdf",
});
await fileActions.addFiles([signedFile]);
await fileActions.addFiles([signedFile], { skipUploadTracking: true });
alert({
alertType: "success",
title: t("success"),
@@ -700,7 +700,7 @@ const SignPopout = ({
const signedFile = new File([response.data], filename, {
type: "application/pdf",
});
await fileActions.addFiles([signedFile]);
await fileActions.addFiles([signedFile], { skipUploadTracking: true });
alert({
alertType: "success",
title: t("success"),
@@ -258,7 +258,7 @@ const SignRequestWorkbenchView = ({ data }: SignRequestWorkbenchViewProps) => {
};
const handleAddToActiveFiles = async () => {
await fileActions.addFiles([pdfFile]);
await fileActions.addFiles([pdfFile], { skipUploadTracking: true });
alert({
alertType: "success",
title: t("success"),
@@ -243,6 +243,7 @@ function FileContextInner({
skipAutoUnzip?: boolean;
/** Persist to IDB without dispatching to workspace state. */
skipWorkspaceDispatch?: boolean;
skipUploadTracking?: boolean;
},
): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
@@ -286,6 +287,7 @@ function FileContextInner({
fileName: string,
) => Promise<boolean>;
allowDuplicates?: boolean;
skipUploadTracking?: boolean;
},
): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
@@ -20,6 +20,7 @@ import { StirlingFile } from "@app/types/fileContext";
import { fileStorage } from "@app/services/fileStorage";
import { zipFileService } from "@app/services/zipFileService";
import { FileAnalyzer } from "@app/services/fileAnalyzer";
import { trackPdfUploaded } from "@app/services/analytics";
const DEBUG = process.env.NODE_ENV === "development";
const HYDRATION_CONCURRENCY = 2;
let activeHydrations = 0;
@@ -252,6 +253,7 @@ interface AddFileOptions {
fileName: string,
) => Promise<boolean>; // Optional callback to confirm extraction of large ZIP files
allowDuplicates?: boolean;
skipUploadTracking?: boolean;
}
/**
@@ -538,6 +540,10 @@ export async function addFiles(
);
}
if (!options.skipUploadTracking && stirlingFiles.length > 0) {
trackPdfUploaded(stirlingFiles);
}
return stirlingFiles;
} finally {
// Always release mutex even if error occurs
@@ -30,6 +30,7 @@ import {
import { createNewStirlingFileStub } from "@app/types/fileContext";
import { ToolOperation } from "@app/types/file";
import { ensureBackendReady } from "@app/services/backendReadinessGuard";
import { trackEditorOperation } from "@app/services/analytics";
import { useWillUseCloud } from "@app/hooks/useWillUseCloud";
import { useCreditCheck } from "@app/hooks/useCreditCheck";
import { notifyPdfProcessingComplete } from "@app/services/desktopNotificationService";
@@ -384,6 +385,11 @@ export const useToolOperation = <TParams>(
}
if (processedFiles.length > 0) {
trackEditorOperation(
config.operationType,
successSourceIds.length || validFiles.length,
);
actions.setFiles(processedFiles);
// Generate thumbnails and download URL concurrently
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const capture = vi.fn();
let optedIn = true;
vi.mock("posthog-js", () => ({
default: {
__loaded: true,
has_opted_in_capturing: () => optedIn,
capture: (...args: unknown[]) => capture(...args),
},
}));
import {
trackPdfUploaded,
trackEditorOperation,
} from "@app/services/analytics";
function pdf(name: string, size = 100): File {
return new File([new Uint8Array(size)], name, { type: "application/pdf" });
}
describe("analytics", () => {
beforeEach(() => {
capture.mockClear();
optedIn = true;
});
it("captures one event per uploaded PDF (no dedup)", () => {
trackPdfUploaded([pdf("a.pdf"), pdf("a.pdf"), pdf("b.pdf")]);
expect(capture).toHaveBeenCalledTimes(3);
expect(capture).toHaveBeenCalledWith("editor_pdf_uploaded", {
source: "editor",
});
});
it("counts every uploaded file regardless of type", () => {
trackPdfUploaded([
new File(["x"], "a.png", { type: "image/png" }),
pdf("b.pdf"),
]);
expect(capture).toHaveBeenCalledTimes(2);
});
it("captures one event per editor operation run", () => {
trackEditorOperation("compress", 3);
expect(capture).toHaveBeenCalledWith("editor_operation", {
source: "editor",
tool: "compress",
file_count: 3,
});
});
it("does not capture when opted out", () => {
optedIn = false;
trackPdfUploaded([pdf("a.pdf")]);
trackEditorOperation("compress", 1);
expect(capture).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
import posthog from "posthog-js";
const DEV = process.env.NODE_ENV === "development";
function canCapture(): boolean {
if (typeof window === "undefined") return false;
const ph = posthog as unknown as {
__loaded?: boolean;
has_opted_in_capturing?: () => boolean;
};
if (!ph.__loaded) return false;
return (
typeof ph.has_opted_in_capturing !== "function" ||
ph.has_opted_in_capturing()
);
}
export function trackPdfUploaded(files: File[]): void {
try {
if (!canCapture() || !files) return;
for (let i = 0; i < files.length; i++) {
posthog.capture("editor_pdf_uploaded", { source: "editor" });
}
} catch (error) {
if (DEV) console.warn("[analytics] trackPdfUploaded failed", error);
}
}
export function trackEditorOperation(toolId: string, fileCount: number): void {
try {
if (!canCapture()) return;
posthog.capture("editor_operation", {
source: "editor",
tool: toolId,
file_count: fileCount,
});
} catch (error) {
if (DEV) console.warn("[analytics] trackEditorOperation failed", error);
}
}
@@ -394,6 +394,7 @@ export async function materializeServerStubs(
autoUnzip: boolean;
skipAutoUnzip: boolean;
allowDuplicates: boolean;
skipUploadTracking?: boolean;
},
) => Promise<StirlingFile[]>;
updateStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
@@ -463,6 +464,7 @@ export async function materializeServerStubs(
autoUnzip: false,
skipAutoUnzip: true,
allowDuplicates: true,
skipUploadTracking: true,
});
if (ingested.length === 0) continue;
const primary = ingested[ingested.length - 1]!;
@@ -306,7 +306,11 @@ export interface FileContextActions {
// File management - lightweight actions only
addFiles: (
files: File[],
options?: { insertAfterPageId?: string; selectFiles?: boolean },
options?: {
insertAfterPageId?: string;
selectFiles?: boolean;
skipUploadTracking?: boolean;
},
) => Promise<StirlingFile[]>;
addFilesWithOptions: (
files: File[],
@@ -321,6 +325,7 @@ export interface FileContextActions {
fileName: string,
) => Promise<boolean>;
allowDuplicates?: boolean;
skipUploadTracking?: boolean;
},
) => Promise<StirlingFile[]>;
addStirlingFileStubs: (
@@ -299,7 +299,10 @@ export function usePolicyAutoRun(): void {
}
interface ImportContext {
addFiles: (files: File[]) => Promise<StirlingFile[]>;
addFiles: (
files: File[],
options?: { skipUploadTracking?: boolean },
) => Promise<StirlingFile[]>;
consumeFiles: (
inputFileIds: FileId[],
outputs: StirlingFile[],
@@ -462,7 +465,7 @@ async function importOutputs(
ctx.bumpRevision();
}
} else {
const added = await ctx.addFiles(files);
const added = await ctx.addFiles(files, { skipUploadTracking: true });
// Same loop-guard for new-file output: the produced file is a new workspace
// file the auto-run would otherwise re-enforce indefinitely.
for (const f of added) markDispatched(run.categoryId, f.fileId);
@@ -1,5 +1,6 @@
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios";
import { withBasePath } from "@app/constants/app";
import { getBrowserId } from "@app/utils/browserIdentifier";
let isRefreshing = false;
let failedQueue: Array<{
@@ -125,6 +126,8 @@ export function setupApiInterceptors(client: AxiosInstance): void {
}
}
config.headers["X-Browser-Id"] = getBrowserId();
return config;
},
(error) => {
@@ -6,6 +6,7 @@ import {
handlePaygError,
} from "@app/services/paygErrorInterceptor";
import { withBasePath } from "@app/constants/app";
import { getBrowserId } from "@app/utils/browserIdentifier";
// Helper: decode base64url JWT payload safely
function decodeJwtPayload(token: string): Record<string, unknown> | null {
@@ -82,6 +83,8 @@ apiClient.interceptors.request.use(
console.error("[API Client] Error in request interceptor:", error);
}
config.headers["X-Browser-Id"] = getBrowserId();
return config;
},
(error) => {