mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 01:08:45 -05:00
refactor: centralize instance ID generation (#6952)
This commit is contained in:
@@ -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
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<TInstanceInfo | null> => {
|
||||
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<string | null> => {
|
||||
const info = await getInstanceInfo();
|
||||
return info?.instanceId ?? null;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TEnterpri
|
||||
// first millisecond of next year => 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<TEnterpri
|
||||
body: JSON.stringify({
|
||||
licenseKey: env.ENTERPRISE_LICENSE_KEY,
|
||||
usage: { responseCount },
|
||||
instanceId,
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user