diff --git a/apps/web/app/api/(internal)/pipeline/lib/telemetry.ts b/apps/web/app/api/(internal)/pipeline/lib/telemetry.ts index 083a6e1a97..44454e1564 100644 --- a/apps/web/app/api/(internal)/pipeline/lib/telemetry.ts +++ b/apps/web/app/api/(internal)/pipeline/lib/telemetry.ts @@ -1,9 +1,9 @@ import { IntegrationType } from "@prisma/client"; -import { createHash } from "node:crypto"; import { type CacheKey, getCacheService } from "@formbricks/cache"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import { env } from "@/lib/env"; +import { getInstanceInfo } from "@/lib/instance"; import packageJson from "@/package.json"; const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -129,15 +129,12 @@ export const sendTelemetryEvents = async () => { * @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics) */ const sendTelemetry = async (lastSent: number) => { - // Get the oldest organization to generate a stable, anonymized instance ID. + // Get the instance info (hashed oldest organization ID and creation date). // Using the oldest org ensures the ID doesn't change over time. - const oldestOrg = await prisma.organization.findFirst({ - orderBy: { createdAt: "asc" }, - select: { id: true, createdAt: true }, - }); + const instanceInfo = await getInstanceInfo(); + if (!instanceInfo) return; // No organization exists, nothing to report - if (!oldestOrg) return; // No organization exists, nothing to report - const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex"); + const { instanceId, createdAt: instanceCreatedAt } = instanceInfo; // Optimize database queries to reduce connection pool usage: // Instead of 15 parallel queries (which could exhaust the connection pool), @@ -248,7 +245,7 @@ const sendTelemetry = async (lastSent: number) => { version: packageJson.version, // Formbricks version for compatibility tracking }, temporal: { - instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created + instanceCreatedAt: instanceCreatedAt.toISOString(), // When instance was first created newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity }, }; diff --git a/apps/web/lib/instance.ts b/apps/web/lib/instance.ts new file mode 100644 index 0000000000..9a8782ea70 --- /dev/null +++ b/apps/web/lib/instance.ts @@ -0,0 +1,50 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { createHash } from "node:crypto"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; + +export type TInstanceInfo = { + instanceId: string; + createdAt: Date; +}; + +/** + * Returns instance info including the anonymized instance ID and creation date. + * + * The instance ID is a SHA-256 hash of the oldest organization's ID, ensuring + * it remains stable over time. Used for telemetry and license checks. + * + * @returns Instance info with hashed ID and creation date, or `null` if no organizations exist + */ +export const getInstanceInfo = reactCache(async (): Promise => { + try { + const oldestOrg = await prisma.organization.findFirst({ + orderBy: { createdAt: "asc" }, + select: { id: true, createdAt: true }, + }); + + if (!oldestOrg) return null; + + return { + instanceId: createHash("sha256").update(oldestOrg.id).digest("hex"), + createdAt: oldestOrg.createdAt, + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}); + +/** + * Convenience function that returns just the instance ID. + * + * @returns Hashed instance ID, or `null` if no organizations exist + */ +export const getInstanceId = async (): Promise => { + const info = await getInstanceInfo(); + return info?.instanceId ?? null; +}; diff --git a/apps/web/modules/ee/license-check/lib/license.test.ts b/apps/web/modules/ee/license-check/lib/license.test.ts index 09b8392fca..88e437f192 100644 --- a/apps/web/modules/ee/license-check/lib/license.test.ts +++ b/apps/web/modules/ee/license-check/lib/license.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { Mock } from "vitest"; import { prisma } from "@formbricks/database"; +import { getInstanceId, getInstanceInfo } from "@/lib/instance"; import { TEnterpriseLicenseDetails, TEnterpriseLicenseFeatures, @@ -55,6 +56,7 @@ vi.mock("@formbricks/database", () => ({ }, organization: { findUnique: vi.fn(), + findFirst: vi.fn(), }, }, })); @@ -70,6 +72,11 @@ vi.mock("@formbricks/logger", () => ({ logger: mockLogger, })); +vi.mock("@/lib/instance", () => ({ + getInstanceId: vi.fn(), + getInstanceInfo: vi.fn(), +})); + // Mock constants as they are used in the original license.ts indirectly vi.mock("@/lib/constants", async (importOriginal) => { const actual = await importOriginal(); @@ -102,6 +109,15 @@ describe("License Core Logic", () => { mockCache.withCache.mockImplementation(async (fn) => await fn()); vi.mocked(prisma.response.count).mockResolvedValue(100); + vi.mocked(prisma.organization.findFirst).mockResolvedValue({ + id: "test-org-id", + createdAt: new Date("2024-01-01"), + } as any); + vi.mocked(getInstanceId).mockResolvedValue("test-hashed-instance-id"); + vi.mocked(getInstanceInfo).mockResolvedValue({ + instanceId: "test-hashed-instance-id", + createdAt: new Date("2024-01-01"), + }); vi.clearAllMocks(); // Mock window to be undefined for server-side tests vi.stubGlobal("window", undefined); diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts index e0eb1ba863..393f2d5f76 100644 --- a/apps/web/modules/ee/license-check/lib/license.ts +++ b/apps/web/modules/ee/license-check/lib/license.ts @@ -9,6 +9,7 @@ import { logger } from "@formbricks/logger"; import { cache } from "@/lib/cache"; import { env } from "@/lib/env"; import { hashString } from "@/lib/hash-string"; +import { getInstanceId } from "@/lib/instance"; import { TEnterpriseLicenseDetails, TEnterpriseLicenseFeatures, @@ -260,14 +261,20 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise current year is fully included const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1); - const responseCount = await prisma.response.count({ - where: { - createdAt: { - gte: startOfYear, - lt: startOfNextYear, + const [instanceId, responseCount] = await Promise.all([ + getInstanceId(), + prisma.response.count({ + where: { + createdAt: { + gte: startOfYear, + lt: startOfNextYear, + }, }, - }, - }); + }), + ]); + + // No organization exists, cannot perform license check + if (!instanceId) return null; const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY; const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; @@ -279,6 +286,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise