mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
attach more telemetry to license check
This commit is contained in:
@@ -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,
|
||||
|
||||
245
apps/web/modules/ee/license-check/lib/telemetry.test.ts
Normal file
245
apps/web/modules/ee/license-check/lib/telemetry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
630
apps/web/modules/ee/license-check/lib/telemetry.ts
Normal file
630
apps/web/modules/ee/license-check/lib/telemetry.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user