attach more telemetry to license check

This commit is contained in:
Johannes
2025-11-18 21:09:33 +01:00
parent 58ab40ab8e
commit 256a0ec81a
3 changed files with 914 additions and 20 deletions

View File

@@ -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<TEnterpriseLicenseDetails | null> => {
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<TEnterpri
const timeoutId = setTimeout(() => 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<TEnterpri
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
trackApiError(error);
// Retry on specific status codes
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
return fetchLicenseFromServerInternal(retryCount + 1);
@@ -341,6 +356,10 @@ export const getEnterpriseLicense = reactCache(
validateConfig();
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
fetchLicenseFromServerInternal().catch((error) => {
logger.debug({ error }, "Background telemetry send failed (no license key)");
});
return {
active: false,
features: null,

View File

@@ -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<string, unknown>),
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<number>((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<any>((resolve) => {
resolveOrgFind = resolve;
});
vi.mocked(prisma.organization.findFirst).mockImplementation(() => orgFindPromise as any);
let resolveOrgCount: (value: number) => void;
const orgCountPromise = new Promise<number>((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);
});
});

View File

@@ -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 = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
return Promise.race([
promise,
new Promise<T | null>((resolve) => {
setTimeout(() => {
logger.warn({ timeoutMs }, "Query timeout exceeded");
resolve(null);
}, timeoutMs);
}),
]);
};
const safeQuery = async <T>(
queryFn: () => Promise<T>,
queryName: string,
batchNumber: number
): Promise<T | null> => {
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<string> => {
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<Partial<TelemetryUsage>> => {
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<TelemetryUsage> = {};
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<string, unknown>)[key] = result.value;
} else {
(batchResult as Record<string, unknown>)[key] = null;
}
}
return batchResult;
};
const collectBatch2 = async (): Promise<Partial<TelemetryUsage>> => {
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<TelemetryUsage> = {};
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<string, unknown>)[key] = result.value;
} else {
(batchResult as Record<string, unknown>)[key] = null;
}
}
return batchResult;
};
const collectBatch3 = async (): Promise<Partial<TelemetryUsage>> => {
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<string, unknown> | 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<TelemetryUsage> = {
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<string, keyof TelemetryUsage["featureUsage"]> = {
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<Partial<TelemetryUsage>> => {
const booleanQueries = [
{
name: "zapier",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "zapier" },
select: { id: true },
});
return result !== null;
},
},
{
name: "make",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "make" },
select: { id: true },
});
return result !== null;
},
},
{
name: "n8n",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "n8n" },
select: { id: true },
});
return result !== null;
},
},
{
name: "webhook",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "user" },
select: { id: true },
});
return result !== null;
},
},
];
const stringQueries = [
{
name: "instanceCreatedAt",
fn: async (): Promise<string | null> => {
const result = await prisma.user.findFirst({
orderBy: { createdAt: "asc" },
select: { createdAt: true },
});
return result?.createdAt.toISOString() ?? null;
},
},
{
name: "newestSurveyDate",
fn: async (): Promise<string | null> => {
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<TelemetryUsage> = {
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<string, keyof TelemetryUsage["activeIntegrations"]> = {
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<TelemetryData> => {
if (IS_FORMBRICKS_CLOUD) {
return {
licenseKey,
usage: null,
};
}
const startTime = Date.now();
try {
const instanceId = await getInstanceId();
const batchPromises = [
Promise.race([
collectBatch1(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 1 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch2(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 2 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch3(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 3 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch4(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 4 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
];
const batchResults = await Promise.race([
Promise.all(batchPromises),
new Promise<Partial<TelemetryUsage>[]>((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,
};
}
};