diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts index e0eb1ba863..d4f0279475 100644 --- a/apps/web/modules/ee/license-check/lib/license.ts +++ b/apps/web/modules/ee/license-check/lib/license.ts @@ -4,7 +4,6 @@ import fetch from "node-fetch"; import { cache as reactCache } from "react"; import { z } from "zod"; import { createCacheKey } from "@formbricks/cache"; -import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import { cache } from "@/lib/cache"; import { env } from "@/lib/env"; @@ -13,6 +12,7 @@ import { TEnterpriseLicenseDetails, TEnterpriseLicenseFeatures, } from "@/modules/ee/license-check/types/enterprise-license"; +import { collectTelemetryData } from "./telemetry"; // Configuration const CONFIG = { @@ -246,29 +246,48 @@ const handleInitialFailure = async (currentTime: Date) => { // API functions const fetchLicenseFromServerInternal = async (retryCount = 0): Promise => { - if (!env.ENTERPRISE_LICENSE_KEY) return null; - // Skip license checks during build time // eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable if (process.env.NEXT_PHASE === "phase-production-build") { return null; } + let telemetryData; try { - const now = new Date(); - const startOfYear = new Date(now.getFullYear(), 0, 1); - // first millisecond of next year => current year is fully included - const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1); + telemetryData = await collectTelemetryData(env.ENTERPRISE_LICENSE_KEY || null); + } catch (telemetryError) { + logger.warn({ error: telemetryError }, "Telemetry collection failed, proceeding with minimal data"); + telemetryData = { + licenseKey: env.ENTERPRISE_LICENSE_KEY || null, + usage: null, + }; + } - const responseCount = await prisma.response.count({ - where: { - createdAt: { - gte: startOfYear, - lt: startOfNextYear, - }, - }, - }); + if (!env.ENTERPRISE_LICENSE_KEY) { + try { + const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY; + const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS); + + await fetch(CONFIG.API.ENDPOINT, { + body: JSON.stringify(telemetryData), + headers: { "Content-Type": "application/json" }, + method: "POST", + agent, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + } catch (error) { + logger.debug({ error }, "Failed to send telemetry (no license key)"); + } + + return null; + } + + try { const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY; const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; @@ -276,10 +295,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise controller.abort(), CONFIG.API.TIMEOUT_MS); const res = await fetch(CONFIG.API.ENDPOINT, { - body: JSON.stringify({ - licenseKey: env.ENTERPRISE_LICENSE_KEY, - usage: { responseCount }, - }), + body: JSON.stringify(telemetryData), headers: { "Content-Type": "application/json" }, method: "POST", agent, @@ -296,7 +312,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise { + logger.debug({ error }, "Background telemetry send failed (no license key)"); + }); + return { active: false, features: null, diff --git a/apps/web/modules/ee/license-check/lib/telemetry.test.ts b/apps/web/modules/ee/license-check/lib/telemetry.test.ts new file mode 100644 index 0000000000..e02c1e44e0 --- /dev/null +++ b/apps/web/modules/ee/license-check/lib/telemetry.test.ts @@ -0,0 +1,245 @@ +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { collectTelemetryData } from "./telemetry"; + +// Mock dependencies +vi.mock("@formbricks/database", () => ({ + prisma: { + organization: { count: vi.fn(), findFirst: vi.fn() }, + user: { count: vi.fn(), findFirst: vi.fn() }, + team: { count: vi.fn() }, + project: { count: vi.fn() }, + survey: { count: vi.fn(), findFirst: vi.fn() }, + contact: { count: vi.fn() }, + segment: { count: vi.fn() }, + display: { count: vi.fn() }, + response: { count: vi.fn() }, + surveyLanguage: { findFirst: vi.fn() }, + surveyAttributeFilter: { findFirst: vi.fn() }, + apiKey: { findFirst: vi.fn() }, + teamUser: { findFirst: vi.fn() }, + surveyQuota: { findFirst: vi.fn() }, + webhook: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + IS_STORAGE_CONFIGURED: true, + IS_RECAPTCHA_CONFIGURED: true, + AUDIT_LOG_ENABLED: true, + GOOGLE_OAUTH_ENABLED: true, + GITHUB_OAUTH_ENABLED: false, + AZURE_OAUTH_ENABLED: false, + OIDC_OAUTH_ENABLED: false, + SAML_OAUTH_ENABLED: false, + AIRTABLE_CLIENT_ID: "test-airtable-id", + SLACK_CLIENT_ID: "test-slack-id", + SLACK_CLIENT_SECRET: "test-slack-secret", + NOTION_OAUTH_CLIENT_ID: "test-notion-id", + NOTION_OAUTH_CLIENT_SECRET: "test-notion-secret", + GOOGLE_SHEETS_CLIENT_ID: "test-sheets-id", + GOOGLE_SHEETS_CLIENT_SECRET: "test-sheets-secret", +})); + +describe("Telemetry Collection", () => { + const mockLicenseKey = "test-license-key-123"; + const mockOrganizationId = "org-123"; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.mocked(prisma.organization.findFirst).mockResolvedValue({ + id: mockOrganizationId, + createdAt: new Date(), + } as any); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("collectTelemetryData", () => { + test("should return null usage for cloud instances", async () => { + // Mock IS_FORMBRICKS_CLOUD as true for this test + const actualConstants = await vi.importActual("@/lib/constants"); + vi.doMock("@/lib/constants", () => ({ + ...(actualConstants as Record), + IS_FORMBRICKS_CLOUD: true, + })); + + // Re-import to get the new mock + const { collectTelemetryData: collectWithCloud } = await import("./telemetry"); + const result = await collectWithCloud(mockLicenseKey); + + expect(result.licenseKey).toBe(mockLicenseKey); + expect(result.usage).toBeNull(); + + // Reset mock + vi.resetModules(); + }); + + test("should collect basic counts successfully", async () => { + vi.mocked(prisma.organization.count).mockResolvedValue(1); + vi.mocked(prisma.user.count).mockResolvedValue(5); + vi.mocked(prisma.team.count).mockResolvedValue(2); + vi.mocked(prisma.project.count).mockResolvedValue(3); + vi.mocked(prisma.survey.count).mockResolvedValue(10); + vi.mocked(prisma.contact.count).mockResolvedValue(100); + vi.mocked(prisma.segment.count).mockResolvedValue(5); + vi.mocked(prisma.display.count).mockResolvedValue(500); + vi.mocked(prisma.response.count).mockResolvedValue(1000); + + const result = await collectTelemetryData(mockLicenseKey); + + expect(result.usage).toBeTruthy(); + if (result.usage) { + expect(result.usage.organizationCount).toBe(1); + expect(result.usage.memberCount).toBe(5); + expect(result.usage.teamCount).toBe(2); + expect(result.usage.projectCount).toBe(3); + expect(result.usage.surveyCount).toBe(10); + expect(result.usage.contactCount).toBe(100); + expect(result.usage.segmentCount).toBe(5); + expect(result.usage.surveyDisplayCount).toBe(500); + expect(result.usage.responseCountAllTime).toBe(1000); + } + }); + + test("should handle query timeouts gracefully", async () => { + // Simulate slow query that times out (but resolve it eventually) + let resolveOrgCount: (value: number) => void; + const orgCountPromise = new Promise((resolve) => { + resolveOrgCount = resolve; + }); + vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any); + + // Mock other queries to return quickly + vi.mocked(prisma.user.count).mockResolvedValue(5); + vi.mocked(prisma.team.count).mockResolvedValue(2); + vi.mocked(prisma.project.count).mockResolvedValue(3); + vi.mocked(prisma.survey.count).mockResolvedValue(10); + vi.mocked(prisma.contact.count).mockResolvedValue(100); + vi.mocked(prisma.segment.count).mockResolvedValue(5); + vi.mocked(prisma.display.count).mockResolvedValue(500); + vi.mocked(prisma.response.count).mockResolvedValue(1000); + + // Mock batch 2 queries + vi.mocked(prisma.survey.findFirst).mockResolvedValue({ id: "survey-1" } as any); + + // Start collection + const resultPromise = collectTelemetryData(mockLicenseKey); + + // Advance timers past the 2s query timeout + await vi.advanceTimersByTimeAsync(3000); + + // Resolve the slow query after timeout + resolveOrgCount!(1); + + const result = await resultPromise; + + // Should still return result, but with null values for timed-out queries + expect(result.usage).toBeTruthy(); + expect(result.usage?.organizationCount).toBeNull(); + // Other queries should still work + expect(result.usage?.memberCount).toBe(5); + }, 15000); + + test("should handle database errors gracefully", async () => { + const dbError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.organization.count).mockRejectedValue(dbError); + vi.mocked(prisma.user.count).mockResolvedValue(5); + + const result = await collectTelemetryData(mockLicenseKey); + + // Should continue despite errors + expect(result.usage).toBeTruthy(); + expect(result.usage?.organizationCount).toBeNull(); + expect(result.usage?.memberCount).toBe(5); + }); + + test("should detect feature usage correctly", async () => { + // Mock feature detection queries + vi.mocked(prisma.surveyLanguage.findFirst).mockResolvedValue({ languageId: "en" } as any); + vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({ + id: "user-2", + twoFactorEnabled: true, + } as any); + vi.mocked(prisma.apiKey.findFirst).mockResolvedValue({ id: "key-1" } as any); + + // Mock all count queries to return 0 to avoid complexity + vi.mocked(prisma.organization.count).mockResolvedValue(0); + vi.mocked(prisma.user.count).mockResolvedValue(0); + vi.mocked(prisma.team.count).mockResolvedValue(0); + vi.mocked(prisma.project.count).mockResolvedValue(0); + vi.mocked(prisma.survey.count).mockResolvedValue(0); + vi.mocked(prisma.contact.count).mockResolvedValue(0); + vi.mocked(prisma.segment.count).mockResolvedValue(0); + vi.mocked(prisma.display.count).mockResolvedValue(0); + vi.mocked(prisma.response.count).mockResolvedValue(0); + + const result = await collectTelemetryData(mockLicenseKey); + + expect(result.usage?.featureUsage).toBeTruthy(); + if (result.usage?.featureUsage) { + expect(result.usage.featureUsage.multiLanguageSurveys).toBe(true); + expect(result.usage.featureUsage.twoFA).toBe(true); + expect(result.usage.featureUsage.apiKeys).toBe(true); + expect(result.usage.featureUsage.sso).toBe(true); // From constants + expect(result.usage.featureUsage.fileUpload).toBe(true); // From constants + } + }); + + test("should generate instance ID when no organization exists", async () => { + vi.mocked(prisma.organization.findFirst).mockResolvedValue(null); + + const result = await collectTelemetryData(mockLicenseKey); + + expect(result.usage).toBeTruthy(); + expect(result.usage?.instanceId).toBeTruthy(); + expect(typeof result.usage?.instanceId).toBe("string"); + }); + + test("should handle total timeout gracefully", async () => { + let resolveOrgFind: (value: any) => void; + const orgFindPromise = new Promise((resolve) => { + resolveOrgFind = resolve; + }); + vi.mocked(prisma.organization.findFirst).mockImplementation(() => orgFindPromise as any); + + let resolveOrgCount: (value: number) => void; + const orgCountPromise = new Promise((resolve) => { + resolveOrgCount = resolve; + }); + vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any); + + // Start collection + const resultPromise = collectTelemetryData(mockLicenseKey); + + // Advance timers past the 15s total timeout + await vi.advanceTimersByTimeAsync(16000); + + resolveOrgFind!({ id: mockOrganizationId, createdAt: new Date() }); + resolveOrgCount!(1); + + const result = await resultPromise; + + // Should return usage object (may be empty or partial) + expect(result.usage).toBeTruthy(); + }, 20000); + }); +}); diff --git a/apps/web/modules/ee/license-check/lib/telemetry.ts b/apps/web/modules/ee/license-check/lib/telemetry.ts new file mode 100644 index 0000000000..f37afa3f6b --- /dev/null +++ b/apps/web/modules/ee/license-check/lib/telemetry.ts @@ -0,0 +1,630 @@ +import "server-only"; +import crypto from "node:crypto"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { + AIRTABLE_CLIENT_ID, + AUDIT_LOG_ENABLED, + AZURE_OAUTH_ENABLED, + GITHUB_OAUTH_ENABLED, + GOOGLE_OAUTH_ENABLED, + GOOGLE_SHEETS_CLIENT_ID, + GOOGLE_SHEETS_CLIENT_SECRET, + IS_FORMBRICKS_CLOUD, + IS_RECAPTCHA_CONFIGURED, + IS_STORAGE_CONFIGURED, + NOTION_OAUTH_CLIENT_ID, + NOTION_OAUTH_CLIENT_SECRET, + OIDC_OAUTH_ENABLED, + SAML_OAUTH_ENABLED, + SLACK_CLIENT_ID, + SLACK_CLIENT_SECRET, +} from "@/lib/constants"; + +const CONFIG = { + QUERY_TIMEOUT_MS: 2000, + BATCH_TIMEOUT_MS: 5000, + TOTAL_TIMEOUT_MS: 15000, +} as const; + +export type TelemetryUsage = { + instanceId: string; + organizationCount: number | null; + memberCount: number | null; + teamCount: number | null; + projectCount: number | null; + surveyCount: number | null; + activeSurveyCount: number | null; + completedSurveyCount: number | null; + responseCountAllTime: number | null; + responseCountLast30d: number | null; + surveyDisplayCount: number | null; + contactCount: number | null; + segmentCount: number | null; + featureUsage: { + multiLanguageSurveys: boolean | null; + advancedTargeting: boolean | null; + sso: boolean | null; + saml: boolean | null; + twoFA: boolean | null; + apiKeys: boolean | null; + teamRoles: boolean | null; + auditLogs: boolean | null; + whitelabel: boolean | null; + removeBranding: boolean | null; + fileUpload: boolean | null; + spamProtection: boolean | null; + quotas: boolean | null; + }; + activeIntegrations: { + airtable: boolean | null; + slack: boolean | null; + notion: boolean | null; + googleSheets: boolean | null; + zapier: boolean | null; + make: boolean | null; + n8n: boolean | null; + webhook: boolean | null; + }; + temporal: { + instanceCreatedAt: string | null; + newestSurveyDate: string | null; + }; +}; + +export type TelemetryData = { + licenseKey: string | null; + usage: TelemetryUsage | null; +}; + +const withTimeout = (promise: Promise, timeoutMs: number): Promise => { + return Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => { + logger.warn({ timeoutMs }, "Query timeout exceeded"); + resolve(null); + }, timeoutMs); + }), + ]); +}; + +const safeQuery = async ( + queryFn: () => Promise, + queryName: string, + batchNumber: number +): Promise => { + try { + const result = await withTimeout(queryFn(), CONFIG.QUERY_TIMEOUT_MS); + return result; + } catch (error) { + logger.error( + { + error, + queryName, + batchNumber, + }, + `Telemetry query failed: ${queryName}` + ); + return null; + } +}; + +const getInstanceId = async (): Promise => { + try { + const firstOrg = await withTimeout( + prisma.organization.findFirst({ + orderBy: { createdAt: "asc" }, + select: { id: true }, + }), + CONFIG.QUERY_TIMEOUT_MS + ); + + if (!firstOrg) { + return crypto.randomUUID(); + } + + return crypto.createHash("sha256").update(firstOrg.id).digest("hex").substring(0, 32); + } catch (error) { + logger.error({ error }, "Failed to get instance ID, using random UUID"); + return crypto.randomUUID(); + } +}; + +const collectBatch1 = async (): Promise> => { + const queries = [ + { + name: "organizationCount", + fn: () => prisma.organization.count(), + }, + { + name: "memberCount", + fn: () => prisma.user.count(), + }, + { + name: "teamCount", + fn: () => prisma.team.count(), + }, + { + name: "projectCount", + fn: () => prisma.project.count(), + }, + { + name: "surveyCount", + fn: () => prisma.survey.count(), + }, + { + name: "contactCount", + fn: () => prisma.contact.count(), + }, + { + name: "segmentCount", + fn: () => prisma.segment.count(), + }, + { + name: "surveyDisplayCount", + fn: () => prisma.display.count(), + }, + { + name: "responseCountAllTime", + fn: () => prisma.response.count(), + }, + ]; + + const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 1))); + + const batchResult: Partial = {}; + for (const [index, result] of results.entries()) { + const key = queries[index].name as keyof TelemetryUsage; + if (result.status === "fulfilled" && result.value !== null) { + (batchResult as Record)[key] = result.value; + } else { + (batchResult as Record)[key] = null; + } + } + + return batchResult; +}; + +const collectBatch2 = async (): Promise> => { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const queries = [ + { + name: "activeSurveyCount", + fn: () => prisma.survey.count({ where: { status: "inProgress" } }), + }, + { + name: "completedSurveyCount", + fn: () => prisma.survey.count({ where: { status: "completed" } }), + }, + { + name: "responseCountLast30d", + fn: () => + prisma.response.count({ + where: { + createdAt: { + gte: thirtyDaysAgo, + }, + }, + }), + }, + ]; + + const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 2))); + + const batchResult: Partial = {}; + for (const [index, result] of results.entries()) { + const key = queries[index].name as keyof TelemetryUsage; + if (result.status === "fulfilled" && result.value !== null) { + (batchResult as Record)[key] = result.value; + } else { + (batchResult as Record)[key] = null; + } + } + + return batchResult; +}; + +const collectBatch3 = async (): Promise> => { + const queries = [ + { + name: "multiLanguageSurveys", + fn: async () => { + const result = await prisma.surveyLanguage.findFirst({ select: { languageId: true } }); + return result !== null; + }, + }, + { + name: "advancedTargeting", + fn: async () => { + const [hasFilters, hasSegments] = await Promise.all([ + prisma.surveyAttributeFilter.findFirst({ select: { id: true } }), + prisma.survey.findFirst({ where: { segmentId: { not: null } } }), + ]); + return hasFilters !== null || hasSegments !== null; + }, + }, + { + name: "twoFA", + fn: async () => { + const result = await prisma.user.findFirst({ + where: { twoFactorEnabled: true }, + select: { id: true }, + }); + return result !== null; + }, + }, + { + name: "apiKeys", + fn: async () => { + const result = await prisma.apiKey.findFirst({ select: { id: true } }); + return result !== null; + }, + }, + { + name: "teamRoles", + fn: async () => { + const result = await prisma.teamUser.findFirst({ select: { teamId: true } }); + return result !== null; + }, + }, + { + name: "whitelabel", + fn: async () => { + const organizations = await prisma.organization.findMany({ + select: { whitelabel: true }, + take: 100, + }); + return organizations.some((org) => { + const whitelabel = org.whitelabel as Record | null; + return whitelabel !== null && typeof whitelabel === "object" && Object.keys(whitelabel).length > 0; + }); + }, + }, + { + name: "removeBranding", + fn: async () => { + const organizations = await prisma.organization.findMany({ + select: { billing: true }, + take: 100, + }); + return organizations.some((org) => { + const billing = org.billing as { plan?: string; removeBranding?: boolean } | null; + return billing?.removeBranding === true; + }); + }, + }, + { + name: "quotas", + fn: async () => { + const result = await prisma.surveyQuota.findFirst({ select: { id: true } }); + return result !== null; + }, + }, + ]; + + const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 3))); + + const batchResult: Partial = { + featureUsage: { + multiLanguageSurveys: null, + advancedTargeting: null, + sso: null, + saml: null, + twoFA: null, + apiKeys: null, + teamRoles: null, + auditLogs: null, + whitelabel: null, + removeBranding: null, + fileUpload: null, + spamProtection: null, + quotas: null, + }, + }; + + const featureMap: Record = { + multiLanguageSurveys: "multiLanguageSurveys", + advancedTargeting: "advancedTargeting", + twoFA: "twoFA", + apiKeys: "apiKeys", + teamRoles: "teamRoles", + whitelabel: "whitelabel", + removeBranding: "removeBranding", + quotas: "quotas", + }; + + for (const [index, result] of results.entries()) { + const queryName = queries[index].name; + const featureKey = featureMap[queryName]; + if (featureKey && batchResult.featureUsage) { + if (result.status === "fulfilled" && result.value !== null) { + batchResult.featureUsage[featureKey] = result.value; + } else { + batchResult.featureUsage[featureKey] = null; + } + } + } + + if (batchResult.featureUsage) { + batchResult.featureUsage.sso = + GOOGLE_OAUTH_ENABLED || + GITHUB_OAUTH_ENABLED || + AZURE_OAUTH_ENABLED || + OIDC_OAUTH_ENABLED || + SAML_OAUTH_ENABLED; + batchResult.featureUsage.saml = SAML_OAUTH_ENABLED; + batchResult.featureUsage.auditLogs = AUDIT_LOG_ENABLED; + batchResult.featureUsage.fileUpload = IS_STORAGE_CONFIGURED; + batchResult.featureUsage.spamProtection = IS_RECAPTCHA_CONFIGURED; + } + + return batchResult; +}; + +const collectBatch4 = async (): Promise> => { + const booleanQueries = [ + { + name: "zapier", + fn: async (): Promise => { + const result = await prisma.webhook.findFirst({ + where: { source: "zapier" }, + select: { id: true }, + }); + return result !== null; + }, + }, + { + name: "make", + fn: async (): Promise => { + const result = await prisma.webhook.findFirst({ + where: { source: "make" }, + select: { id: true }, + }); + return result !== null; + }, + }, + { + name: "n8n", + fn: async (): Promise => { + const result = await prisma.webhook.findFirst({ + where: { source: "n8n" }, + select: { id: true }, + }); + return result !== null; + }, + }, + { + name: "webhook", + fn: async (): Promise => { + const result = await prisma.webhook.findFirst({ + where: { source: "user" }, + select: { id: true }, + }); + return result !== null; + }, + }, + ]; + + const stringQueries = [ + { + name: "instanceCreatedAt", + fn: async (): Promise => { + const result = await prisma.user.findFirst({ + orderBy: { createdAt: "asc" }, + select: { createdAt: true }, + }); + return result?.createdAt.toISOString() ?? null; + }, + }, + { + name: "newestSurveyDate", + fn: async (): Promise => { + const result = await prisma.survey.findFirst({ + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }); + return result?.createdAt.toISOString() ?? null; + }, + }, + ]; + + const booleanResults = await Promise.allSettled( + booleanQueries.map((query) => safeQuery(query.fn, query.name, 4)) + ); + const stringResults = await Promise.allSettled( + stringQueries.map((query) => safeQuery(query.fn, query.name, 4)) + ); + + const batchResult: Partial = { + activeIntegrations: { + airtable: null, + slack: null, + notion: null, + googleSheets: null, + zapier: null, + make: null, + n8n: null, + webhook: null, + }, + temporal: { + instanceCreatedAt: null, + newestSurveyDate: null, + }, + }; + + const integrationMap: Record = { + zapier: "zapier", + make: "make", + n8n: "n8n", + webhook: "webhook", + }; + + for (const [index, result] of booleanResults.entries()) { + const queryName = booleanQueries[index].name; + const integrationKey = integrationMap[queryName]; + if (integrationKey && batchResult.activeIntegrations) { + if (result.status === "fulfilled" && result.value !== null) { + batchResult.activeIntegrations[integrationKey] = result.value; + } else { + batchResult.activeIntegrations[integrationKey] = null; + } + } + } + + for (const [index, result] of stringResults.entries()) { + const queryName = stringQueries[index].name; + if (batchResult.temporal && (queryName === "instanceCreatedAt" || queryName === "newestSurveyDate")) { + if (result.status === "fulfilled" && result.value !== null) { + batchResult.temporal[queryName] = result.value; + } + } + } + + if (batchResult.activeIntegrations) { + batchResult.activeIntegrations.airtable = !!AIRTABLE_CLIENT_ID; + batchResult.activeIntegrations.slack = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET); + batchResult.activeIntegrations.notion = !!(NOTION_OAUTH_CLIENT_ID && NOTION_OAUTH_CLIENT_SECRET); + batchResult.activeIntegrations.googleSheets = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET); + } + + return batchResult; +}; + +export const collectTelemetryData = async (licenseKey: string | null): Promise => { + if (IS_FORMBRICKS_CLOUD) { + return { + licenseKey, + usage: null, + }; + } + + const startTime = Date.now(); + + try { + const instanceId = await getInstanceId(); + + const batchPromises = [ + Promise.race([ + collectBatch1(), + new Promise>((resolve) => { + setTimeout(() => { + logger.warn("Batch 1 timeout"); + resolve({}); + }, CONFIG.BATCH_TIMEOUT_MS); + }), + ]), + Promise.race([ + collectBatch2(), + new Promise>((resolve) => { + setTimeout(() => { + logger.warn("Batch 2 timeout"); + resolve({}); + }, CONFIG.BATCH_TIMEOUT_MS); + }), + ]), + Promise.race([ + collectBatch3(), + new Promise>((resolve) => { + setTimeout(() => { + logger.warn("Batch 3 timeout"); + resolve({}); + }, CONFIG.BATCH_TIMEOUT_MS); + }), + ]), + Promise.race([ + collectBatch4(), + new Promise>((resolve) => { + setTimeout(() => { + logger.warn("Batch 4 timeout"); + resolve({}); + }, CONFIG.BATCH_TIMEOUT_MS); + }), + ]), + ]; + + const batchResults = await Promise.race([ + Promise.all(batchPromises), + new Promise[]>((resolve) => { + setTimeout(() => { + logger.warn("Total telemetry collection timeout"); + resolve([{}, {}, {}, {}]); + }, CONFIG.TOTAL_TIMEOUT_MS); + }), + ]); + + const usage: TelemetryUsage = { + instanceId, + organizationCount: null, + memberCount: null, + teamCount: null, + projectCount: null, + surveyCount: null, + activeSurveyCount: null, + completedSurveyCount: null, + responseCountAllTime: null, + responseCountLast30d: null, + surveyDisplayCount: null, + contactCount: null, + segmentCount: null, + featureUsage: { + multiLanguageSurveys: null, + advancedTargeting: null, + sso: null, + saml: null, + twoFA: null, + apiKeys: null, + teamRoles: null, + auditLogs: null, + whitelabel: null, + removeBranding: null, + fileUpload: null, + spamProtection: null, + quotas: null, + }, + activeIntegrations: { + airtable: null, + slack: null, + notion: null, + googleSheets: null, + zapier: null, + make: null, + n8n: null, + webhook: null, + }, + temporal: { + instanceCreatedAt: null, + newestSurveyDate: null, + }, + }; + + for (const batchResult of batchResults) { + Object.assign(usage, batchResult); + if (batchResult.featureUsage) { + Object.assign(usage.featureUsage, batchResult.featureUsage); + } + if (batchResult.activeIntegrations) { + Object.assign(usage.activeIntegrations, batchResult.activeIntegrations); + } + if (batchResult.temporal) { + Object.assign(usage.temporal, batchResult.temporal); + } + } + + const duration = Date.now() - startTime; + logger.info({ duration, instanceId }, "Telemetry collection completed"); + + return { + licenseKey, + usage, + }; + } catch (error) { + logger.error({ error, duration: Date.now() - startTime }, "Telemetry collection failed completely"); + return { + licenseKey, + usage: null, + }; + } +};