From c3ca0d548d97b356543158f3b9228efa8a3fe347 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Sat, 6 Jun 2026 20:27:33 +0300 Subject: [PATCH] enhancement(env): add metrics configuration and validation --- .env.example | 1 + .env.production | 24 +++++++++ .github/workflows/build.yml | 1 + .github/workflows/ci.yml | 1 + .github/workflows/release-please.yml | 1 + Dockerfile.prod | 4 +- src/shared/config/env.client.ts | 50 +++++++++++++++++++ src/shared/config/env.ts | 14 ++++-- .../config/metrics/FrontendObservability.tsx | 2 + 9 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 .env.production create mode 100644 src/shared/config/env.client.ts diff --git a/.env.example b/.env.example index a3d490d..a6aeea4 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ NEXT_PUBLIC_FARO_APP_NAME=next-frontend NEXT_PUBLIC_FARO_APP_NAMESPACE=nextjs-example NEXT_PUBLIC_FARO_APP_VERSION=1.0.0 NEXT_PUBLIC_APP_ENV=development +NEXT_PUBLIC_METRICS_ENABLED=false # Next App BACKEND Instrumentation ## Example assumes that the collector is running on the same machine diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..a8e5356 --- /dev/null +++ b/.env.production @@ -0,0 +1,24 @@ +NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/v1 +PORT=3000 +# Next App FRONTEND Instrumentation +NEXT_PUBLIC_FARO_URL=http://localhost:12347/collect +NEXT_PUBLIC_FARO_APP_NAME=ttopen-frontend +NEXT_PUBLIC_FARO_APP_NAMESPACE=ttopen-front +NEXT_PUBLIC_FARO_APP_VERSION=1.0.0 +NEXT_PUBLIC_APP_ENV=development +NEXT_PUBLIC_METRICS_ENABLED=true + +# Next App BACKEND Instrumentation +## Example assumes that the collector is running on the same machine +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +## Force protobuf +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +## Set Backend service name +OTEL_SERVICE_NAME=next-backend +## Customize resource attributes, namespace is a recommended attribute +OTEL_RESOURCE_ATTRIBUTES=service.namespace=nextjs-ttopen + +# OTel collector +GRAFANA_CLOUD_USERNAME= +GRAFANA_CLOUD_API_KEY= +GRAFANA_CLOUD_ENDPOINT= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 83b9c6d..f7f2bbc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,6 +76,7 @@ jobs: NEXT_PUBLIC_APP_ENV=${{ vars.NEXT_PUBLIC_APP_ENV }} NEXT_PUBLIC_FARO_APP_NAME=${{ vars.NEXT_PUBLIC_FARO_APP_NAME }} NEXT_PUBLIC_FARO_APP_NAMESPACE=${{ vars.NEXT_PUBLIC_FARO_APP_NAMESPACE }} + NEXT_PUBLIC_METRICS_ENABLED=${{ vars.NEXT_PUBLIC_METRICS_ENABLED }} SKIP_ENV_VALIDATION=true push: ${{ (env.IS_PUSH == 'true' && env.IS_BASE_BRANCH == 'true') || env.FORCE_PUSH == 'true' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9af3b3a..5259c41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,5 +82,6 @@ jobs: NEXT_PUBLIC_APP_ENV: ${{ vars.NEXT_PUBLIC_APP_ENV }} NEXT_PUBLIC_FARO_APP_NAME: ${{ vars.NEXT_PUBLIC_FARO_APP_NAME }} NEXT_PUBLIC_FARO_APP_NAMESPACE: ${{ vars.NEXT_PUBLIC_FARO_APP_NAMESPACE }} + NEXT_PUBLIC_METRICS_ENABLED: ${{vars.NEXT_PUBLIC_METRICS_ENABLED}} # - name: Build Storybook # run: pnpm build-storybook diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 41f37c0..4d77bc0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -69,6 +69,7 @@ jobs: NEXT_PUBLIC_APP_ENV=${{ vars.NEXT_PUBLIC_APP_ENV }} NEXT_PUBLIC_FARO_APP_NAME=${{ vars.NEXT_PUBLIC_FARO_APP_NAME }} NEXT_PUBLIC_FARO_APP_NAMESPACE=${{ vars.NEXT_PUBLIC_FARO_APP_NAMESPACE }} + NEXT_PUBLIC_METRICS_ENABLED=${{vars.NEXT_PUBLIC_METRICS_ENABLED}} SKIP_ENV_VALIDATION=true push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile.prod b/Dockerfile.prod index 58cd06a..1164134 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -20,6 +20,7 @@ ARG NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_FARO_URL ARG NEXT_PUBLIC_FARO_APP_VERSION ARG NEXT_PUBLIC_APP_ENV +ARG NEXT_PUBLIC_METRICS_ENABLED ARG SKIP_ENV_VALIDATION=false ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ @@ -29,7 +30,8 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ NEXT_PUBLIC_FARO_APP_VERSION=$NEXT_PUBLIC_FARO_APP_VERSION \ NEXT_PUBLIC_APP_ENV=$NEXT_PUBLIC_APP_ENV \ SKIP_ENV_VALIDATION=$SKIP_ENV_VALIDATION \ - NEXT_TELEMETRY_DISABLED=1 + NEXT_TELEMETRY_DISABLED=1 \ + NEXT_PUBLIC_METRICS_ENABLED=$NEXT_PUBLIC_METRICS_ENABLED COPY --from=deps /app/node_modules ./node_modules COPY . . diff --git a/src/shared/config/env.client.ts b/src/shared/config/env.client.ts new file mode 100644 index 0000000..d819f5d --- /dev/null +++ b/src/shared/config/env.client.ts @@ -0,0 +1,50 @@ +import { z } from 'zod/v4'; + +export const envSchemaClient = z.object({ + NEXT_PUBLIC_API_BASE_URL: z.url('NEXT_PUBLIC_API_BASE_URL должен быть валидным URL'), + NEXT_PUBLIC_FARO_URL: z.url( + 'NEXT_PUBLIC_FARO_URL должен быть валидным URL (например, http://alloy:12347/collect)' + ), + NEXT_PUBLIC_FARO_APP_NAME: z + .string({ + error: 'Имя приложения для Faro обязательно', + }) + .min(1, 'Имя приложения не может быть пустым'), + NEXT_PUBLIC_FARO_APP_NAMESPACE: z + .string({ + error: 'Namespace приложения обязателен', + }) + .min(1, 'Namespace не может быть пустым'), + NEXT_PUBLIC_FARO_APP_VERSION: z.string().default('1.0.0'), + NEXT_PUBLIC_APP_ENV: z + .string({ + error: 'Окружение (APP_ENV) обязательно', + }) + .min(1, 'Окружение не может быть пустым'), + NEXT_PUBLIC_METRICS_ENABLED: z + .enum(['true', 'false'], { + error: 'NEXT_PUBLIC_METRICS_ENABLED - обязателен', + }) + .transform((v) => v === 'true'), +}); + +const _env = envSchemaClient.safeParse({ + NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL, + NEXT_PUBLIC_FARO_URL: process.env.NEXT_PUBLIC_FARO_URL, + NEXT_PUBLIC_FARO_APP_NAME: process.env.NEXT_PUBLIC_FARO_APP_NAME, + NEXT_PUBLIC_FARO_APP_NAMESPACE: process.env.NEXT_PUBLIC_FARO_APP_NAMESPACE, + NEXT_PUBLIC_FARO_APP_VERSION: process.env.NEXT_PUBLIC_FARO_APP_VERSION, + NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV, + NEXT_PUBLIC_METRICS_ENABLED: process.env.NEXT_PUBLIC_METRICS_ENABLED, +}); + +if (!_env.success) { + console.error('❌ [ENV VALIDATION ERROR]:', z.treeifyError(_env.error).properties); + if (typeof window === 'undefined') { + throw new Error('Client env validation failed on server-side rendering'); + } +} + +export type ClientEnv = z.infer; + +export const env: ClientEnv = _env.success ? _env.data : ({} as ClientEnv); diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index 70eaf50..112c9bc 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -3,6 +3,10 @@ import { z } from 'zod/v4'; const isServer = typeof window === 'undefined'; const isBuild = process.env.SKIP_ENV_VALIDATION === 'true'; +const metricEnabledSchema = z.enum(['true', 'false'], { + error: 'NEXT_PUBLIC_METRICS_ENABLED - обязателен', +}); + const envSchemaServer = z.object({ NODE_ENV: z .enum(['development', 'production', 'test'], { @@ -35,11 +39,7 @@ const envSchemaServer = z.object({ }); const envSchemaClient = z.object({ - NEXT_PUBLIC_API_BASE_URL: z - .string({ - error: 'API Base URL обязателен', - }) - .url('NEXT_PUBLIC_API_BASE_URL должен быть валидным URL'), + NEXT_PUBLIC_API_BASE_URL: z.url('NEXT_PUBLIC_API_BASE_URL должен быть валидным URL'), NEXT_PUBLIC_FARO_URL: z .string({ error: 'URL для Faro (Alloy) обязателен', @@ -61,6 +61,10 @@ const envSchemaClient = z.object({ error: 'Окружение (APP_ENV) обязательно', }) .min(1, 'Окружение не может быть пустым'), + NEXT_PUBLIC_METRICS_ENABLED: + process.env.NODE_ENV === 'development' + ? metricEnabledSchema.default('false').transform((v) => v === 'true') + : metricEnabledSchema.transform((v) => v === 'true'), }); const envSchema = envSchemaClient.extend(envSchemaServer.shape); diff --git a/src/shared/config/metrics/FrontendObservability.tsx b/src/shared/config/metrics/FrontendObservability.tsx index 4ea4b6a..56f858e 100644 --- a/src/shared/config/metrics/FrontendObservability.tsx +++ b/src/shared/config/metrics/FrontendObservability.tsx @@ -3,11 +3,13 @@ import { useEffect } from 'react'; import { faro, getWebInstrumentations, initializeFaro } from '@grafana/faro-web-sdk'; import { TracingInstrumentation } from '@grafana/faro-web-tracing'; +import { env } from '../env.client'; let isFaroInitialized = false; export default function FrontendObservability() { useEffect(() => { + if (!env.NEXT_PUBLIC_METRICS_ENABLED) return; const initializeWhenIdle = () => { if (isFaroInitialized || faro.api) { return;