mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-10 18:58:44 -06:00
Compare commits
8 Commits
chore/remo
...
signoz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25d9a86a0b | ||
|
|
b6dd81cbe5 | ||
|
|
07a6cd6c0e | ||
|
|
335da2f1f5 | ||
|
|
13b9db915b | ||
|
|
76b25476b3 | ||
|
|
04220902b4 | ||
|
|
4649a2de3e |
@@ -184,8 +184,13 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# OpenTelemetry URL for tracing
|
||||
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
|
||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
# OTEL_SERVICE_NAME=formbricks
|
||||
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
|
||||
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
|
||||
# OTEL_TRACES_SAMPLER_ARG=1
|
||||
|
||||
# Unsplash API Key
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
@@ -32,7 +33,12 @@ export const DeleteOrganization = ({
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await deleteOrganizationAction({ organizationId: organization.id });
|
||||
const result = await deleteOrganizationAction({ organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.general.organization_deleted_successfully"));
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
@@ -268,7 +269,14 @@ export const AddIntegrationModal = ({
|
||||
airtableIntegrationData.config?.data.push(integrationData);
|
||||
}
|
||||
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: airtableIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: airtableIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
if (isEditMode) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
@@ -304,7 +312,11 @@ export const AddIntegrationModal = ({
|
||||
const integrationData = structuredClone(airtableIntegrationData);
|
||||
integrationData.config.data.splice(index, 1);
|
||||
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData });
|
||||
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
router.refresh();
|
||||
|
||||
|
||||
@@ -165,7 +165,14 @@ export const AddIntegrationModal = ({
|
||||
// create action
|
||||
googleSheetIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: googleSheetIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
@@ -205,7 +212,14 @@ export const AddIntegrationModal = ({
|
||||
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: googleSheetIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
@@ -266,7 +280,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{surveyElements.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
createEmptyMapping,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -217,7 +218,14 @@ export const AddIntegrationModal = ({
|
||||
notionIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: notionIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
@@ -236,7 +244,14 @@ export const AddIntegrationModal = ({
|
||||
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: notionIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
@@ -144,7 +145,14 @@ export const AddChannelMappingModal = ({
|
||||
// create action
|
||||
slackIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: slackIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
@@ -181,7 +189,14 @@ export const AddChannelMappingModal = ({
|
||||
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: slackIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,59 +1,200 @@
|
||||
// instrumentation-node.ts
|
||||
// OpenTelemetry instrumentation for Next.js - loaded via instrumentation.ts hook
|
||||
// Pattern based on: ee/src/opentelemetry.ts (license server)
|
||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
||||
import { HostMetrics } from "@opentelemetry/host-metrics";
|
||||
import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
||||
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
|
||||
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import {
|
||||
detectResources,
|
||||
envDetector,
|
||||
hostDetector,
|
||||
processDetector,
|
||||
resourceFromAttributes,
|
||||
} from "@opentelemetry/resources";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
AlwaysOffSampler,
|
||||
AlwaysOnSampler,
|
||||
BatchSpanProcessor,
|
||||
ParentBasedSampler,
|
||||
type Sampler,
|
||||
TraceIdRatioBasedSampler,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
||||
import { PrismaInstrumentation } from "@prisma/instrumentation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const exporter = new PrometheusExporter({
|
||||
port: env.PROMETHEUS_EXPORTER_PORT ? parseInt(env.PROMETHEUS_EXPORTER_PORT) : 9464,
|
||||
endpoint: "/metrics",
|
||||
host: "0.0.0.0", // Listen on all network interfaces
|
||||
});
|
||||
// --- Configuration from environment ---
|
||||
const serviceName = process.env.OTEL_SERVICE_NAME || "formbricks";
|
||||
const serviceVersion = process.env.npm_package_version || "0.0.0";
|
||||
const environment = process.env.ENVIRONMENT || process.env.NODE_ENV || "development";
|
||||
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
const prometheusEnabled = process.env.PROMETHEUS_ENABLED === "1";
|
||||
const prometheusPort = process.env.PROMETHEUS_EXPORTER_PORT
|
||||
? Number.parseInt(process.env.PROMETHEUS_EXPORTER_PORT)
|
||||
: 9464;
|
||||
|
||||
const detectedResources = detectResources({
|
||||
detectors: [envDetector, processDetector, hostDetector],
|
||||
});
|
||||
// --- Configure OTLP exporters (conditional on endpoint being set) ---
|
||||
let traceExporter: OTLPTraceExporter | undefined;
|
||||
let otlpMetricExporter: OTLPMetricExporter | undefined;
|
||||
|
||||
const customResources = resourceFromAttributes({});
|
||||
|
||||
const resources = detectedResources.merge(customResources);
|
||||
|
||||
const meterProvider = new MeterProvider({
|
||||
readers: [exporter],
|
||||
resource: resources,
|
||||
});
|
||||
|
||||
const hostMetrics = new HostMetrics({
|
||||
name: `otel-metrics`,
|
||||
meterProvider,
|
||||
});
|
||||
|
||||
registerInstrumentations({
|
||||
meterProvider,
|
||||
instrumentations: [new HttpInstrumentation(), new RuntimeNodeInstrumentation()],
|
||||
});
|
||||
|
||||
hostMetrics.start();
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
if (otlpEndpoint) {
|
||||
try {
|
||||
// Stop collecting metrics or flush them if needed
|
||||
await meterProvider.shutdown();
|
||||
// Possibly close other instrumentation resources
|
||||
// OTLPTraceExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
|
||||
// and appends /v1/traces for HTTP transport
|
||||
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively (W3C OTel format: key=value,key2=value2)
|
||||
traceExporter = new OTLPTraceExporter();
|
||||
|
||||
// OTLPMetricExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
|
||||
// and appends /v1/metrics for HTTP transport
|
||||
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively
|
||||
otlpMetricExporter = new OTLPMetricExporter();
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to create OTLP exporters. Telemetry will not be exported.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Configure Prometheus exporter (pull-based metrics for ServiceMonitor) ---
|
||||
let prometheusExporter: PrometheusExporter | undefined;
|
||||
if (prometheusEnabled) {
|
||||
prometheusExporter = new PrometheusExporter({
|
||||
port: prometheusPort,
|
||||
endpoint: "/metrics",
|
||||
host: "0.0.0.0",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Build metric readers array ---
|
||||
const metricReaders: (PeriodicExportingMetricReader | PrometheusExporter)[] = [];
|
||||
|
||||
if (otlpMetricExporter) {
|
||||
metricReaders.push(
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter: otlpMetricExporter,
|
||||
exportIntervalMillis: 60000, // Export every 60 seconds
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (prometheusExporter) {
|
||||
metricReaders.push(prometheusExporter);
|
||||
}
|
||||
|
||||
// --- Resource attributes ---
|
||||
const resourceAttributes: Record<string, string> = {
|
||||
[ATTR_SERVICE_NAME]: serviceName,
|
||||
[ATTR_SERVICE_VERSION]: serviceVersion,
|
||||
"deployment.environment": environment,
|
||||
};
|
||||
|
||||
// --- Configure sampler ---
|
||||
const samplerType = process.env.OTEL_TRACES_SAMPLER || "always_on";
|
||||
const parsedSamplerArg = process.env.OTEL_TRACES_SAMPLER_ARG
|
||||
? Number.parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG)
|
||||
: undefined;
|
||||
const samplerArg =
|
||||
parsedSamplerArg !== undefined && !Number.isNaN(parsedSamplerArg) ? parsedSamplerArg : undefined;
|
||||
|
||||
let sampler: Sampler;
|
||||
switch (samplerType) {
|
||||
case "always_on":
|
||||
sampler = new AlwaysOnSampler();
|
||||
break;
|
||||
case "always_off":
|
||||
sampler = new AlwaysOffSampler();
|
||||
break;
|
||||
case "traceidratio":
|
||||
sampler = new TraceIdRatioBasedSampler(samplerArg ?? 1);
|
||||
break;
|
||||
case "parentbased_traceidratio":
|
||||
sampler = new ParentBasedSampler({
|
||||
root: new TraceIdRatioBasedSampler(samplerArg ?? 1),
|
||||
});
|
||||
break;
|
||||
case "parentbased_always_on":
|
||||
sampler = new ParentBasedSampler({
|
||||
root: new AlwaysOnSampler(),
|
||||
});
|
||||
break;
|
||||
case "parentbased_always_off":
|
||||
sampler = new ParentBasedSampler({
|
||||
root: new AlwaysOffSampler(),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
logger.warn(`Unknown sampler type: ${samplerType}. Using always_on.`);
|
||||
sampler = new AlwaysOnSampler();
|
||||
}
|
||||
|
||||
// --- Initialize NodeSDK ---
|
||||
const sdk = new NodeSDK({
|
||||
sampler,
|
||||
resource: resourceFromAttributes(resourceAttributes),
|
||||
spanProcessor: traceExporter
|
||||
? new BatchSpanProcessor(traceExporter, {
|
||||
maxQueueSize: 2048,
|
||||
maxExportBatchSize: 512,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000,
|
||||
})
|
||||
: undefined,
|
||||
metricReaders: metricReaders.length > 0 ? metricReaders : undefined,
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
// Disable noisy/unnecessary instrumentations
|
||||
"@opentelemetry/instrumentation-fs": {
|
||||
enabled: false,
|
||||
},
|
||||
"@opentelemetry/instrumentation-dns": {
|
||||
enabled: false,
|
||||
},
|
||||
"@opentelemetry/instrumentation-net": {
|
||||
enabled: false,
|
||||
},
|
||||
// Disable pg instrumentation - PrismaInstrumentation handles DB tracing
|
||||
"@opentelemetry/instrumentation-pg": {
|
||||
enabled: false,
|
||||
},
|
||||
"@opentelemetry/instrumentation-http": {
|
||||
// Ignore health/metrics endpoints to reduce noise
|
||||
ignoreIncomingRequestHook: (req) => {
|
||||
const url = req.url || "";
|
||||
return url === "/health" || url.startsWith("/metrics") || url === "/api/v2/health";
|
||||
},
|
||||
},
|
||||
// Enable runtime metrics for Node.js process monitoring
|
||||
"@opentelemetry/instrumentation-runtime-node": {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
// Prisma instrumentation for database query tracing
|
||||
new PrismaInstrumentation(),
|
||||
],
|
||||
});
|
||||
|
||||
// Start the SDK
|
||||
sdk.start();
|
||||
|
||||
// --- Log initialization status ---
|
||||
const enabledFeatures: string[] = [];
|
||||
if (traceExporter) enabledFeatures.push("traces");
|
||||
if (otlpMetricExporter) enabledFeatures.push("otlp-metrics");
|
||||
if (prometheusExporter) enabledFeatures.push("prometheus-metrics");
|
||||
|
||||
const samplerArgStr = process.env.OTEL_TRACES_SAMPLER_ARG || "";
|
||||
const samplerArgMsg = samplerArgStr ? `, samplerArg=${samplerArgStr}` : "";
|
||||
|
||||
if (enabledFeatures.length > 0) {
|
||||
logger.info(
|
||||
`OpenTelemetry initialized: service=${serviceName}, version=${serviceVersion}, environment=${environment}, exporters=${enabledFeatures.join("+")}, sampler=${samplerType}${samplerArgMsg}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`OpenTelemetry initialized (no exporters): service=${serviceName}, version=${serviceVersion}, environment=${environment}`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Graceful shutdown ---
|
||||
// Run before other SIGTERM listeners (logger flush, etc.) so spans are drained first.
|
||||
process.prependListener("SIGTERM", async () => {
|
||||
try {
|
||||
await sdk.shutdown();
|
||||
} catch (e) {
|
||||
logger.error(e, "Error during graceful shutdown");
|
||||
} finally {
|
||||
process.exit(0);
|
||||
logger.error(e, "Error during OpenTelemetry shutdown");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,10 +5,13 @@ export const onRequestError = Sentry.captureRequestError;
|
||||
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
if (PROMETHEUS_ENABLED) {
|
||||
// Load OpenTelemetry instrumentation when Prometheus metrics or OTLP export is enabled
|
||||
if (PROMETHEUS_ENABLED || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
||||
await import("./instrumentation-node");
|
||||
}
|
||||
}
|
||||
// Sentry init loads after OTEL to avoid TracerProvider conflicts
|
||||
// Sentry tracing is disabled (tracesSampleRate: 0) -- SigNoz handles distributed tracing
|
||||
if (process.env.NEXT_RUNTIME === "nodejs" && IS_PRODUCTION && SENTRY_DSN) {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ export const env = createEnv({
|
||||
OIDC_DISPLAY_NAME: z.string().optional(),
|
||||
OIDC_ISSUER: z.string().optional(),
|
||||
OIDC_SIGNING_ALGORITHM: z.string().optional(),
|
||||
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
|
||||
REDIS_URL:
|
||||
process.env.NODE_ENV === "test"
|
||||
? z.string().optional()
|
||||
@@ -174,7 +173,6 @@ export const env = createEnv({
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
|
||||
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
|
||||
|
||||
@@ -141,5 +141,52 @@ describe("Time Utilities", () => {
|
||||
expect(convertDatesInObject("string")).toBe("string");
|
||||
expect(convertDatesInObject(123)).toBe(123);
|
||||
});
|
||||
|
||||
test("should not convert dates in contactAttributes", () => {
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
contactAttributes: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.contactAttributes.email).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("should not convert dates in variables", () => {
|
||||
const input = {
|
||||
updatedAt: "2024-03-20T15:30:00",
|
||||
variables: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
userId: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.variables.userId).toBe("123");
|
||||
});
|
||||
|
||||
test("should not convert dates in data or meta", () => {
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
data: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
},
|
||||
meta: {
|
||||
updatedAt: "2024-03-20T17:30:00",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +160,12 @@ export const convertDatesInObject = <T>(obj: T): T => {
|
||||
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
|
||||
}
|
||||
const newObj: any = {};
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
for (const key in obj) {
|
||||
if (keysToIgnore.has(key)) {
|
||||
newObj[key] = obj[key];
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(key === "createdAt" || key === "updatedAt") &&
|
||||
typeof obj[key] === "string" &&
|
||||
|
||||
@@ -109,7 +109,13 @@ export function SegmentSettings({
|
||||
const handleDeleteSegment = async () => {
|
||||
try {
|
||||
setIsDeletingSegment(true);
|
||||
await deleteSegmentAction({ segmentId: segment.id });
|
||||
const result = await deleteSegmentAction({ segmentId: segment.id });
|
||||
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeletingSegment(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeletingSegment(false);
|
||||
toast.success(t("environments.segments.segment_deleted_successfully"));
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
cloneSegmentAction,
|
||||
createSegmentAction,
|
||||
@@ -135,7 +136,11 @@ export function TargetingCard({
|
||||
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
|
||||
try {
|
||||
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
|
||||
await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.segments.segment_saved_successfully"));
|
||||
|
||||
setIsSegmentEditorOpen(false);
|
||||
|
||||
@@ -154,7 +154,12 @@ export function EditLanguage({
|
||||
|
||||
const performLanguageDeletion = async (languageId: string) => {
|
||||
try {
|
||||
await deleteLanguageAction({ languageId, projectId: project.id });
|
||||
const result = await deleteLanguageAction({ languageId, projectId: project.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setConfirmationModal((prev) => ({ ...prev, isOpen: false }));
|
||||
return;
|
||||
}
|
||||
setLanguages((prev) => prev.filter((lang) => lang.id !== languageId));
|
||||
toast.success(t("environments.workspace.languages.language_deleted_successfully"));
|
||||
// Close the modal after deletion
|
||||
@@ -187,7 +192,7 @@ export function EditLanguage({
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!validateLanguages(languages, t)) return;
|
||||
await Promise.all(
|
||||
const results = await Promise.all(
|
||||
languages.map((lang) => {
|
||||
return lang.id === "new"
|
||||
? createLanguageAction({
|
||||
@@ -201,6 +206,11 @@ export function EditLanguage({
|
||||
});
|
||||
})
|
||||
);
|
||||
const errorResult = results.find((result) => result?.serverError);
|
||||
if (errorResult) {
|
||||
toast.error(getFormattedErrorMessage(errorResult));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.workspace.languages.languages_updated_successfully"));
|
||||
router.refresh();
|
||||
setIsEditing(false);
|
||||
@@ -239,7 +249,7 @@ export function EditLanguage({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 italic">
|
||||
<p className="text-sm italic text-slate-500">
|
||||
{t("environments.workspace.languages.no_language_found")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions";
|
||||
import { TTeam } from "@/modules/ee/teams/team-list/types/team";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -27,6 +28,12 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
|
||||
setIsDeleting(true);
|
||||
|
||||
const deleteTeamActionResponse = await deleteTeamAction({ teamId });
|
||||
if (deleteTeamActionResponse?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(deleteTeamActionResponse));
|
||||
setIsDeleteDialogOpen(false);
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
if (deleteTeamActionResponse?.data) {
|
||||
toast.success(t("environments.settings.teams.team_deleted_successfully"));
|
||||
onDelete?.();
|
||||
|
||||
@@ -42,14 +42,27 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
if (!member && invite) {
|
||||
// This is an invite
|
||||
|
||||
await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.general.invite_deleted_successfully"));
|
||||
}
|
||||
|
||||
if (member && !invite) {
|
||||
// This is a member
|
||||
|
||||
await deleteMembershipAction({ userId: member.userId, organizationId: organization.id });
|
||||
const result = await deleteMembershipAction({
|
||||
userId: member.userId,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.general.member_deleted_successfully"));
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,12 @@ export const OrganizationActions = ({
|
||||
const handleLeaveOrganization = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await leaveOrganizationAction({ organizationId: organization.id });
|
||||
const result = await leaveOrganizationAction({ organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.general.member_deleted_successfully"));
|
||||
router.refresh();
|
||||
setLoading(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FormProvider, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
deleteActionClassAction,
|
||||
updateActionClassAction,
|
||||
@@ -92,10 +93,14 @@ export const ActionSettingsTab = ({
|
||||
validatePermissions(isReadOnly, t);
|
||||
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
|
||||
|
||||
await updateActionClassAction({
|
||||
const result = await updateActionClassAction({
|
||||
actionClassId: actionClass.id,
|
||||
updatedAction: updatedAction,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
toast.success(t("environments.actions.action_updated_successfully"));
|
||||
@@ -109,7 +114,11 @@ export const ActionSettingsTab = ({
|
||||
const handleDeleteAction = async () => {
|
||||
try {
|
||||
setIsDeletingAction(true);
|
||||
await deleteActionClassAction({ actionClassId: actionClass.id });
|
||||
const result = await deleteActionClassAction({ actionClassId: actionClass.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
toast.success(t("environments.actions.action_deleted_successfully"));
|
||||
setOpen(false);
|
||||
|
||||
@@ -69,6 +69,7 @@ export const EndScreenForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div>
|
||||
{endingCard.subheader !== undefined && (
|
||||
@@ -87,6 +88,7 @@ export const EndScreenForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface PictureSelectionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const PictureSelectionForm = ({
|
||||
@@ -39,6 +40,7 @@ export const PictureSelectionForm = ({
|
||||
isInvalid,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: PictureSelectionFormProps): JSX.Element => {
|
||||
const environmentId = localSurvey.environmentId;
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -88,6 +90,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
autoFocus={!element.headline?.default || element.headline.default.trim() === ""}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
@@ -106,6 +109,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
autoFocus={!element.subheader?.default || element.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -188,6 +188,8 @@ export const FollowUpModal = ({
|
||||
subject: defaultValues?.subject ?? t("environments.surveys.edit.follow_ups_modal_action_subject"),
|
||||
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
|
||||
attachResponseData: defaultValues?.attachResponseData ?? false,
|
||||
includeVariables: defaultValues?.includeVariables ?? false,
|
||||
includeHiddenFields: defaultValues?.includeHiddenFields ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZCreateSurveyFollowUpFormSchema),
|
||||
mode: "onChange",
|
||||
|
||||
@@ -69,7 +69,11 @@ export const SurveyDropDownMenu = ({
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction({ surveyId });
|
||||
const result = await deleteSurveyAction({ surveyId });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
deleteSurvey(surveyId);
|
||||
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,7 +20,24 @@ const nextConfig = {
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
productionBrowserSourceMaps: true,
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
serverExternalPackages: [
|
||||
"@aws-sdk",
|
||||
"@opentelemetry/api",
|
||||
"@opentelemetry/auto-instrumentations-node",
|
||||
"@opentelemetry/exporter-metrics-otlp-http",
|
||||
"@opentelemetry/exporter-prometheus",
|
||||
"@opentelemetry/exporter-trace-otlp-http",
|
||||
"@opentelemetry/instrumentation",
|
||||
"@opentelemetry/resources",
|
||||
"@opentelemetry/sdk-metrics",
|
||||
"@opentelemetry/sdk-node",
|
||||
"@opentelemetry/sdk-trace-base",
|
||||
"@opentelemetry/semantic-conventions",
|
||||
"@prisma/instrumentation",
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
"pino-opentelemetry-transport",
|
||||
],
|
||||
outputFileTracingIncludes: {
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
|
||||
@@ -45,13 +45,16 @@
|
||||
"@lexical/react": "0.36.2",
|
||||
"@lexical/rich-text": "0.36.2",
|
||||
"@lexical/table": "0.36.2",
|
||||
"@opentelemetry/exporter-prometheus": "0.203.0",
|
||||
"@opentelemetry/host-metrics": "0.38.0",
|
||||
"@opentelemetry/instrumentation": "0.203.0",
|
||||
"@opentelemetry/instrumentation-http": "0.203.0",
|
||||
"@opentelemetry/instrumentation-runtime-node": "0.17.1",
|
||||
"@opentelemetry/sdk-logs": "0.203.0",
|
||||
"@opentelemetry/sdk-metrics": "2.0.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "0.69.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.211.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.211.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.211.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
"@opentelemetry/sdk-metrics": "2.5.0",
|
||||
"@opentelemetry/sdk-node": "0.211.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "1.38.0",
|
||||
"@prisma/instrumentation": "6.14.0",
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"@prisma/client": "6.14.0",
|
||||
"@radix-ui/react-accordion": "1.2.10",
|
||||
|
||||
107
apps/web/playwright/survey-follow-up.spec.ts
Normal file
107
apps/web/playwright/survey-follow-up.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("Survey Follow-Up Create & Edit", async () => {
|
||||
// 3 minutes
|
||||
test.setTimeout(1000 * 60 * 3);
|
||||
|
||||
test("Create a follow-up without optional toggles and verify it saves", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
await test.step("Create a new survey", async () => {
|
||||
await page.getByText("Start from scratch").click();
|
||||
await page.getByRole("button", { name: "Create survey", exact: true }).click();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/);
|
||||
});
|
||||
|
||||
await test.step("Navigate to Follow-ups tab", async () => {
|
||||
await page.getByText("Follow-ups").click();
|
||||
// Verify the empty state is shown
|
||||
await expect(page.getByText("Send automatic follow-ups")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Create a new follow-up without enabling optional toggles", async () => {
|
||||
// Click the "New follow-up" button in the empty state
|
||||
await page.getByRole("button", { name: "New follow-up" }).click();
|
||||
|
||||
// Verify the modal is open
|
||||
await expect(page.getByText("Create a new follow-up")).toBeVisible();
|
||||
|
||||
// Fill in the follow-up name
|
||||
await page.getByPlaceholder("Name your follow-up").fill("Test Follow-Up");
|
||||
|
||||
// Leave trigger as default ("Respondent completes survey")
|
||||
// Leave "Attach response data" toggle OFF (the key scenario for the bug)
|
||||
// Leave "Include variables" and "Include hidden fields" unchecked
|
||||
|
||||
// Click Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear — this was the bug: previously save failed silently
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step("Verify follow-up appears in the list", async () => {
|
||||
// After creation, the modal closes and the follow-up should appear in the list
|
||||
await expect(page.getByText("Test Follow-Up")).toBeVisible();
|
||||
await expect(page.getByText("Any response")).toBeVisible();
|
||||
await expect(page.getByText("Send email")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Edit the follow-up and verify it saves", async () => {
|
||||
// Click on the follow-up to edit it
|
||||
await page.getByText("Test Follow-Up").click();
|
||||
|
||||
// Verify the edit modal opens
|
||||
await expect(page.getByText("Edit this follow-up")).toBeVisible();
|
||||
|
||||
// Change the name
|
||||
const nameInput = page.getByPlaceholder("Name your follow-up");
|
||||
await nameInput.clear();
|
||||
await nameInput.fill("Updated Follow-Up");
|
||||
|
||||
// Save the edit
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
|
||||
// Verify the updated name appears in the list
|
||||
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Create a second follow-up with optional toggles enabled", async () => {
|
||||
// Click "+ New follow-up" button (now in the non-empty state header)
|
||||
await page.getByRole("button", { name: /New follow-up/ }).click();
|
||||
|
||||
// Verify the modal is open
|
||||
await expect(page.getByText("Create a new follow-up")).toBeVisible();
|
||||
|
||||
// Fill in the follow-up name
|
||||
await page.getByPlaceholder("Name your follow-up").fill("Follow-Up With Data");
|
||||
|
||||
// Enable "Attach response data" toggle
|
||||
await page.locator("#attachResponseData").click();
|
||||
|
||||
// Check both optional checkboxes
|
||||
await page.locator("#includeVariables").click();
|
||||
await page.locator("#includeHiddenFields").click();
|
||||
|
||||
// Click Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
|
||||
// Verify both follow-ups appear in the list
|
||||
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
|
||||
await expect(page.getByText("Follow-Up With Data")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,9 @@ if (SENTRY_DSN) {
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Keep Sentry from registering its own TracerProvider; app telemetry is handled by instrumentation-node.ts
|
||||
skipOpenTelemetrySetup: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -30,7 +33,7 @@ if (SENTRY_DSN) {
|
||||
const error = hint.originalException as Error;
|
||||
|
||||
// @ts-expect-error
|
||||
if (error && error.digest === "NEXT_NOT_FOUND") {
|
||||
if (error?.digest === "NEXT_NOT_FOUND") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,8 +169,13 @@ x-environment: &environment
|
||||
# Set the below to 1 to disable Rate Limiting across Formbricks
|
||||
# RATE_LIMITING_DISABLED: 1
|
||||
|
||||
# Set the below to send OpenTelemetry data for tracing
|
||||
# OPENTELEMETRY_LISTENER_URL: http://localhost:4318/v1/traces
|
||||
# Set the below to send OpenTelemetry data via OTLP to your collector
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT: http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
|
||||
# OTEL_SERVICE_NAME: formbricks
|
||||
# OTEL_RESOURCE_ATTRIBUTES: deployment.environment=development
|
||||
# OTEL_TRACES_SAMPLER: parentbased_traceidratio
|
||||
# OTEL_TRACES_SAMPLER_ARG: 1
|
||||
|
||||
########################################## OPTIONAL (AUDIT LOGGING) ###########################################
|
||||
|
||||
|
||||
@@ -59,7 +59,12 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
|
||||
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
|
||||
| OTEL_EXPORTER_OTLP_ENDPOINT | Base OTLP HTTP endpoint for traces and metrics export (e.g. http://collector:4318). | optional | |
|
||||
| OTEL_EXPORTER_OTLP_PROTOCOL | OTLP protocol to use for export. | optional | http/protobuf |
|
||||
| OTEL_SERVICE_NAME | Service name reported in OpenTelemetry resource attributes. | optional | formbricks |
|
||||
| OTEL_RESOURCE_ATTRIBUTES | Comma-separated resource attributes in OTel format (`key=value,key2=value2`). | optional | |
|
||||
| OTEL_TRACES_SAMPLER | Trace sampler strategy (`always_on`, `always_off`, `traceidratio`, `parentbased_traceidratio`). | optional | always_on |
|
||||
| OTEL_TRACES_SAMPLER_ARG | Sampling argument used by ratio-based samplers (`0` to `1`). | optional | |
|
||||
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
|
||||
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
|
||||
| DEFAULT_TEAM_ID | Default team ID for new users. | optional | |
|
||||
|
||||
@@ -115,7 +115,7 @@ export const setup = async (
|
||||
|
||||
const expiresAt = existingConfig.status.expiresAt;
|
||||
|
||||
if (expiresAt && isNowExpired(new Date(expiresAt))) {
|
||||
if (expiresAt && !isNowExpired(new Date(expiresAt))) {
|
||||
console.error("🧱 Formbricks - Error state is not expired, skipping initialization");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
|
||||
import { setIsSetup } from "@/lib/common/status";
|
||||
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
|
||||
import { filterSurveys, getIsDebug, isNowExpired } from "@/lib/common/utils";
|
||||
import type * as Utils from "@/lib/common/utils";
|
||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||
@@ -56,6 +56,7 @@ vi.mock("@/lib/common/utils", async (importOriginal) => {
|
||||
...originalModule,
|
||||
filterSurveys: vi.fn(),
|
||||
isNowExpired: vi.fn(),
|
||||
getIsDebug: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -86,6 +87,7 @@ describe("setup.ts", () => {
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -117,7 +119,8 @@ describe("setup.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("skips setup if existing config is in error state and not expired", async () => {
|
||||
test("skips setup if existing config is in error state and not expired (debug mode)", async () => {
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(true);
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
@@ -131,7 +134,7 @@ describe("setup.ts", () => {
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(true);
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(false); // Not expired
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -140,6 +143,59 @@ describe("setup.ts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("skips initialization if error state is active (not expired)", async () => {
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(false);
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
appUrl: "https://my.url",
|
||||
environment: {},
|
||||
user: { data: {}, expiresAt: null },
|
||||
status: { value: "error", expiresAt: new Date(Date.now() + 10000) },
|
||||
}),
|
||||
resetConfig: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(false); // Time is NOT up
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
// Should NOT fetch environment or user state
|
||||
expect(fetchEnvironmentState).not.toHaveBeenCalled();
|
||||
expect(mockConfig.resetConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("continues initialization if error state is expired", async () => {
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(false);
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
appUrl: "https://my.url",
|
||||
environment: { data: { surveys: [] }, expiresAt: new Date() },
|
||||
user: { data: {}, expiresAt: null },
|
||||
status: { value: "error", expiresAt: new Date(Date.now() - 10000) },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(true); // Time IS up
|
||||
|
||||
// Mock successful fetch to allow setup to proceed
|
||||
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: { data: { surveys: [] }, expiresAt: new Date() },
|
||||
});
|
||||
(filterSurveys as unknown as Mock).mockReturnValue([]);
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fetchEnvironmentState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"dependencies": {
|
||||
"zod": "3.24.4",
|
||||
"pino": "10.0.0",
|
||||
"pino-opentelemetry-transport": "2.0.0",
|
||||
"pino-pretty": "13.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -43,26 +43,71 @@ const baseLoggerConfig: LoggerOptions = {
|
||||
name: "formbricks",
|
||||
};
|
||||
|
||||
const developmentConfig: LoggerOptions = {
|
||||
...baseLoggerConfig,
|
||||
transport: {
|
||||
/**
|
||||
* Build transport configuration based on environment.
|
||||
* - Development: pino-pretty for readable console output
|
||||
* - Production: JSON to stdout (default Pino behavior)
|
||||
* - Both: optional pino-opentelemetry-transport for SigNoz log correlation when OTEL is configured
|
||||
*/
|
||||
const buildTransport = (): LoggerOptions["transport"] => {
|
||||
const hasOtelEndpoint =
|
||||
process.env.NEXT_RUNTIME === "nodejs" && Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
|
||||
|
||||
const serviceName = process.env.OTEL_SERVICE_NAME ?? "formbricks";
|
||||
const serviceVersion = process.env.npm_package_version ?? "0.0.0";
|
||||
|
||||
const otelTarget = {
|
||||
target: "pino-opentelemetry-transport",
|
||||
options: {
|
||||
resourceAttributes: {
|
||||
"service.name": serviceName,
|
||||
"service.version": serviceVersion,
|
||||
"deployment.environment": process.env.ENVIRONMENT ?? process.env.NODE_ENV ?? "development",
|
||||
},
|
||||
},
|
||||
level: getLogLevel(),
|
||||
};
|
||||
|
||||
const prettyTarget = {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
levelFirst: true,
|
||||
translateTime: "SYS:standard",
|
||||
ignore: "pid,hostname,ip,requestId",
|
||||
customLevels: "trace:10,debug:20,info:30,audit:35,warn:40,error:50,fatal:60",
|
||||
customLevels: "trace:10,debug:20,info:30,warn:40,error:50,fatal:60,audit:90",
|
||||
useOnlyCustomProps: true,
|
||||
},
|
||||
},
|
||||
level: getLogLevel(),
|
||||
};
|
||||
|
||||
if (!IS_PRODUCTION) {
|
||||
// Development: pretty print + optional OTEL
|
||||
if (hasOtelEndpoint) {
|
||||
return { targets: [prettyTarget, otelTarget] };
|
||||
}
|
||||
return { target: prettyTarget.target, options: prettyTarget.options };
|
||||
}
|
||||
|
||||
// Production: stdout JSON + optional OTEL
|
||||
if (hasOtelEndpoint) {
|
||||
const fileTarget = {
|
||||
target: "pino/file",
|
||||
options: { destination: 1 }, // stdout
|
||||
level: getLogLevel(),
|
||||
};
|
||||
return { targets: [fileTarget, otelTarget] };
|
||||
}
|
||||
|
||||
return undefined; // Default JSON to stdout
|
||||
};
|
||||
|
||||
const productionConfig: LoggerOptions = {
|
||||
const loggerConfig: LoggerOptions = {
|
||||
...baseLoggerConfig,
|
||||
transport: buildTransport(),
|
||||
};
|
||||
|
||||
const pinoLogger: Logger = IS_PRODUCTION ? Pino(productionConfig) : Pino(developmentConfig);
|
||||
const pinoLogger: Logger = Pino(loggerConfig);
|
||||
|
||||
// Ensure all log levels are properly bound
|
||||
const boundLogger = {
|
||||
|
||||
@@ -19,6 +19,8 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
*/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
// Keep dist when running watch so surveys (and others) can resolve types during parallel go
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
formats: ["es"],
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@formbricks/survey-ui": ["../survey-ui"]
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
|
||||
1539
pnpm-lock.yaml
generated
1539
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
15
turbo.json
15
turbo.json
@@ -88,16 +88,16 @@
|
||||
"persistent": true
|
||||
},
|
||||
"@formbricks/surveys#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "@formbricks/survey-ui#build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"@formbricks/surveys#build:dev": {
|
||||
"dependsOn": ["^build:dev", "@formbricks/i18n-utils#build"],
|
||||
"dependsOn": ["^build:dev", "@formbricks/i18n-utils#build", "@formbricks/survey-ui#build:dev"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"@formbricks/surveys#go": {
|
||||
"cache": false,
|
||||
"dependsOn": ["@formbricks/surveys#build"],
|
||||
"dependsOn": ["@formbricks/survey-ui#build", "@formbricks/surveys#build"],
|
||||
"persistent": true
|
||||
},
|
||||
"@formbricks/surveys#test": {
|
||||
@@ -194,11 +194,18 @@
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_API_HOST",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID",
|
||||
"OPENTELEMETRY_LISTENER_URL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_RESOURCE_ATTRIBUTES",
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_TRACES_SAMPLER",
|
||||
"OTEL_TRACES_SAMPLER_ARG",
|
||||
"NEXT_RUNTIME",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXTAUTH_URL",
|
||||
"NODE_ENV",
|
||||
"npm_package_version",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_DISPLAY_NAME",
|
||||
|
||||
Reference in New Issue
Block a user