Compare commits

...

1 Commits

Author SHA1 Message Date
Bhagya Amarasinghe
b6dd81cbe5 feat: integrate OpenTelemetry for enhanced monitoring and tracing
- Updated environment variables to use OTLP for tracing and metrics.
- Added OpenTelemetry SDK and exporters for tracing and metrics.
- Configured instrumentation for Next.js to support OpenTelemetry.
- Enhanced logging with pino-opentelemetry-transport for log correlation.
- Updated documentation for new environment variables related to OpenTelemetry.
2026-02-10 13:47:29 +05:30
13 changed files with 1711 additions and 205 deletions

View File

@@ -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=

View File

@@ -1,59 +1,188 @@
// 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 samplerArg = process.env.OTEL_TRACES_SAMPLER_ARG
? Number.parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG)
: 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;
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");
}
});

View File

@@ -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");
}

View File

@@ -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,

View File

@@ -20,7 +20,23 @@ const nextConfig = {
output: "standalone",
poweredByHeader: false,
productionBrowserSourceMaps: true,
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
serverExternalPackages: [
"@aws-sdk",
"@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/**/*"],
},

View File

@@ -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.68.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.210.0",
"@opentelemetry/exporter-prometheus": "0.210.0",
"@opentelemetry/exporter-trace-otlp-http": "0.210.0",
"@opentelemetry/resources": "2.4.0",
"@opentelemetry/sdk-metrics": "2.4.0",
"@opentelemetry/sdk-node": "0.210.0",
"@opentelemetry/sdk-trace-base": "2.4.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",

View File

@@ -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;
}

View File

@@ -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) ###########################################

View File

@@ -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 | |

View File

@@ -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": {

View File

@@ -43,9 +43,32 @@ 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,
@@ -55,14 +78,36 @@ const developmentConfig: LoggerOptions = {
customLevels: "trace:10,debug:20,info:30,audit:35,warn:40,error:50,fatal:60",
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 = {

1550
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",