diff --git a/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java new file mode 100644 index 0000000000..a99e31a2df --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java @@ -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 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; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 367c875744..dac9816018 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -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 diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java new file mode 100644 index 0000000000..173ca6e551 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java @@ -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 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; + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java b/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java new file mode 100644 index 0000000000..08a8a8b762 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java @@ -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 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()); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java new file mode 100644 index 0000000000..997e5936c8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java @@ -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 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 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 captureEvent() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(postHogService).captureEvent(eq("pdf_operation_metrics"), captor.capture()); + return captor.getValue(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 3ca3841265..20a9cb2628 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -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( diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index c0a78d56e0..967b725b5e 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -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); diff --git a/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts index 2aca4a1d07..35167564f7 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts @@ -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)); diff --git a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx index 3e7eb0c9bd..aece0cb2da 100644 --- a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx +++ b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx @@ -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"), diff --git a/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx b/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx index 04756abdfe..fa2512fd04 100644 --- a/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx @@ -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"), diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index 6621bf0efd..0840d49a72 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -243,6 +243,7 @@ function FileContextInner({ skipAutoUnzip?: boolean; /** Persist to IDB without dispatching to workspace state. */ skipWorkspaceDispatch?: boolean; + skipUploadTracking?: boolean; }, ): Promise => { const stirlingFiles = await addFiles( @@ -286,6 +287,7 @@ function FileContextInner({ fileName: string, ) => Promise; allowDuplicates?: boolean; + skipUploadTracking?: boolean; }, ): Promise => { const stirlingFiles = await addFiles( diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index c4d6ff1f88..749e820e41 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -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; // 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 diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index 52b4ce379d..5629dfe54b 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -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 = ( } if (processedFiles.length > 0) { + trackEditorOperation( + config.operationType, + successSourceIds.length || validFiles.length, + ); + actions.setFiles(processedFiles); // Generate thumbnails and download URL concurrently diff --git a/frontend/editor/src/core/services/analytics.test.ts b/frontend/editor/src/core/services/analytics.test.ts new file mode 100644 index 0000000000..3334066cb9 --- /dev/null +++ b/frontend/editor/src/core/services/analytics.test.ts @@ -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(); + }); +}); diff --git a/frontend/editor/src/core/services/analytics.ts b/frontend/editor/src/core/services/analytics.ts new file mode 100644 index 0000000000..f4ea317eab --- /dev/null +++ b/frontend/editor/src/core/services/analytics.ts @@ -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); + } +} diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts index 8d80563f8e..5e948f8ba7 100644 --- a/frontend/editor/src/core/services/fileSyncService.ts +++ b/frontend/editor/src/core/services/fileSyncService.ts @@ -394,6 +394,7 @@ export async function materializeServerStubs( autoUnzip: boolean; skipAutoUnzip: boolean; allowDuplicates: boolean; + skipUploadTracking?: boolean; }, ) => Promise; updateStub: (id: FileId, updates: Partial) => 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]!; diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index 3e9ba4283d..120e39b751 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -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; addFilesWithOptions: ( files: File[], @@ -321,6 +325,7 @@ export interface FileContextActions { fileName: string, ) => Promise; allowDuplicates?: boolean; + skipUploadTracking?: boolean; }, ) => Promise; addStirlingFileStubs: ( diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index a0b2a02bae..8440d53b5b 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -299,7 +299,10 @@ export function usePolicyAutoRun(): void { } interface ImportContext { - addFiles: (files: File[]) => Promise; + addFiles: ( + files: File[], + options?: { skipUploadTracking?: boolean }, + ) => Promise; 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); diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 18b13861d5..8615156e9a 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -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) => { diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index aeb6bb5275..64cd05fd01 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -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 | 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) => {