From c5e7480d708dc4a5423d451c1b8198686b70c8e1 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 14 Oct 2025 13:32:23 -0400 Subject: [PATCH 1/5] feat: add alloy to with-graphql-prisma --- .../with-graphql-prisma/apps/api/Dockerfile | 44 +++++- .../with-graphql-prisma/apps/api/alloy.config | 136 ++++++++++++++++++ .../with-graphql-prisma/apps/api/package.json | 1 + .../with-graphql-prisma/apps/api/src/app.ts | 64 ++++----- .../apps/api/src/plugins/metrics.ts | 85 +++++++++++ .../with-graphql-prisma/apps/api/start.sh | 39 +++++ .../docker-compose.observability.yml | 58 ++++++++ .../packages/feature-flags/package.json | 24 +++- .../packages/utils/tsconfig.json | 3 +- 9 files changed, 408 insertions(+), 46 deletions(-) create mode 100644 examples/with-graphql-prisma/apps/api/alloy.config create mode 100644 examples/with-graphql-prisma/apps/api/src/plugins/metrics.ts create mode 100644 examples/with-graphql-prisma/apps/api/start.sh create mode 100644 examples/with-graphql-prisma/docker-compose.observability.yml diff --git a/examples/with-graphql-prisma/apps/api/Dockerfile b/examples/with-graphql-prisma/apps/api/Dockerfile index dc8ee3a2..98e6c5a2 100644 --- a/examples/with-graphql-prisma/apps/api/Dockerfile +++ b/examples/with-graphql-prisma/apps/api/Dockerfile @@ -1,8 +1,7 @@ FROM node:22-alpine AS base ENV SCOPE=@repo/api -ARG APP_PATH -ENV APP_PATH=$APP_PATH +ENV APP_PATH=apps/api ### Builder FROM base AS builder @@ -52,17 +51,34 @@ FROM base AS runner # Environment and dependencies ENV NODE_ENV production -RUN apk add --no-cache tzdata +RUN apk add --no-cache tzdata curl bash unzip libc6-compat libstdc++ RUN apk update ENV TZ=America/Caracas WORKDIR /app +# Install Grafana Alloy (detect architecture) +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + ALLOY_ARCH="amd64"; \ + elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \ + ALLOY_ARCH="arm64"; \ + else \ + echo "Unsupported architecture: $ARCH" && exit 1; \ + fi && \ + curl -L -o /tmp/alloy.zip "https://github.com/grafana/alloy/releases/latest/download/alloy-linux-${ALLOY_ARCH}.zip" && \ + unzip /tmp/alloy.zip -d /tmp && \ + mv /tmp/alloy-linux-${ALLOY_ARCH} /usr/local/bin/alloy && \ + chmod +x /usr/local/bin/alloy && \ + rm /tmp/alloy.zip + # User RUN addgroup --system --gid 1001 avilatek RUN adduser --system --uid 1001 avilatek -USER avilatek -# Copy dist + +# Copy dist and dependencies COPY --from=installer /app/package*.json . +COPY --from=installer /app/node_modules ./node_modules +COPY --from=installer /app/packages ./packages COPY --from=installer /app/${APP_PATH}/dist ./${APP_PATH}/dist COPY --from=installer /app/${APP_PATH}/src/*.env* ./${APP_PATH}/src/*.env* @@ -70,10 +86,26 @@ COPY --from=installer /app/${APP_PATH}/package.json ./${APP_PATH}/package.json # Prisma COPY --from=installer /app/${APP_PATH}/prisma ./${APP_PATH}/prisma +# Copy Alloy configuration +COPY --from=builder /app/${APP_PATH}/alloy.config ./${APP_PATH}/alloy.config + +# Copy startup script +COPY --from=builder /app/${APP_PATH}/start.sh /app/start.sh +RUN chmod +x /app/start.sh + +# Give write permissions to prisma directory for SQLite +RUN chown -R avilatek:avilatek /app/${APP_PATH}/prisma + +# Give permissions to alloy storage path and startup script +RUN mkdir -p /tmp/alloy && \ + chown -R avilatek:avilatek /tmp/alloy && \ + chown avilatek:avilatek /app/start.sh + +USER avilatek ARG PORT ENV PORT=$PORT EXPOSE $PORT WORKDIR /app/${APP_PATH} -CMD ["npm", "start"] +CMD ["/bin/bash", "/app/start.sh"] diff --git a/examples/with-graphql-prisma/apps/api/alloy.config b/examples/with-graphql-prisma/apps/api/alloy.config new file mode 100644 index 00000000..eed5679b --- /dev/null +++ b/examples/with-graphql-prisma/apps/api/alloy.config @@ -0,0 +1,136 @@ +// Grafana Alloy Configuration for API Application +// This configuration collects logs and metrics from the Node.js API + +// ============================================================================ +// LOGGING PIPELINE +// ============================================================================ + +// Collect logs from the application's log file +loki.source.file "app_logs" { + targets = [ + { + __path__ = "/tmp/api.log", + job = env("APP_NAME"), + app = env("APP_NAME"), + env = env("ENVIRONMENT"), + instance = env("HOSTNAME"), + }, + ] + + forward_to = [loki.process.add_labels.receiver] +} + +// Process and enrich logs +loki.process "add_labels" { + // Add additional labels + stage.static_labels { + values = { + cluster = env("CLUSTER_NAME"), + service = env("APP_NAME"), + } + } + + // Parse JSON logs if they exist + stage.json { + expressions = { + level = "level", + message = "msg", + timestamp = "time", + } + } + + // Extract log level + stage.labels { + values = { + level = "level", + } + } + + forward_to = [loki.write.default.receiver] +} + +// Send logs to Loki +loki.write "default" { + endpoint { + url = env("LOKI_URL") + + // Optional: Add basic auth if needed + basic_auth { + username = env("LOKI_USERNAME") + password = env("LOKI_PASSWORD") + } + } + + external_labels = { + cluster = env("CLUSTER_NAME"), + } +} + +// ============================================================================ +// METRICS PIPELINE +// ============================================================================ + +// Scrape metrics from the application (if it exposes /metrics) +prometheus.scrape "app_metrics" { + targets = [ + { + __address__ = "localhost:" + env("APP_PORT"), + job = env("APP_NAME"), + app = env("APP_NAME"), + env = env("ENVIRONMENT"), + }, + ] + + forward_to = [prometheus.relabel.add_labels.receiver] + scrape_interval = "15s" + metrics_path = "/metrics" +} + +// Add labels to metrics +prometheus.relabel "add_labels" { + rule { + target_label = "cluster" + replacement = env("CLUSTER_NAME") + } + + rule { + target_label = "instance" + replacement = env("HOSTNAME") + } + + forward_to = [prometheus.remote_write.default.receiver] +} + +// Send metrics to Prometheus/Mimir +prometheus.remote_write "default" { + endpoint { + url = env("PROMETHEUS_REMOTE_WRITE_URL") + + // Optional: Add basic auth if needed + basic_auth { + username = env("PROMETHEUS_USERNAME") + password = env("PROMETHEUS_PASSWORD") + } + } + + external_labels = { + cluster = env("CLUSTER_NAME"), + env = env("ENVIRONMENT"), + } +} + +// ============================================================================ +// PROCESS METRICS (CPU, Memory, etc.) +// ============================================================================ + +// Collect host/container metrics +prometheus.exporter.unix "node" { + include_exporter_metrics = true +} + +prometheus.scrape "node_metrics" { + targets = prometheus.exporter.unix.node.targets + forward_to = [prometheus.remote_write.default.receiver] + job_name = "node-exporter" +} + diff --git a/examples/with-graphql-prisma/apps/api/package.json b/examples/with-graphql-prisma/apps/api/package.json index c2de39ee..73d1ea04 100644 --- a/examples/with-graphql-prisma/apps/api/package.json +++ b/examples/with-graphql-prisma/apps/api/package.json @@ -54,6 +54,7 @@ "pino-pretty": "13.0.0", "pino-loki": "2.6.0", "postmark": "4.0.5", + "prom-client": "15.1.3", "@repo/feature-flags": "*", "slug": "11.0.0", "ts-node": "10.9.2", diff --git a/examples/with-graphql-prisma/apps/api/src/app.ts b/examples/with-graphql-prisma/apps/api/src/app.ts index 3859dcfa..d1bdcf5c 100644 --- a/examples/with-graphql-prisma/apps/api/src/app.ts +++ b/examples/with-graphql-prisma/apps/api/src/app.ts @@ -3,6 +3,7 @@ import './instrument'; import { Server } from 'http'; import { schema } from '@/graphql/schema'; import { prismaPlugin } from '@/plugins/prisma'; +import metricsPlugin from '@/plugins/metrics'; import { ApolloServer } from '@apollo/server'; import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled'; import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'; @@ -24,25 +25,17 @@ import { const provider = process.env.FEATURE_FLAG_PROVIDER as TFeatureFlagProvider; export async function createApp() { - let config: FastifyHttpOptions = {}; - - if (process.env.NODE_ENV === 'production') { - config = { - logger: { - level: 'info', - transport: { - target: '@axiomhq/pino', - options: { - dataset: process.env.AXIOM_DATASET, - token: process.env.AXIOM_TOKEN, - }, - }, - }, - }; - } + let config: FastifyHttpOptions = { + logger: { + level: process.env.LOG_LEVEL || 'info', + }, + }; const app = Fastify(config); + // Register metrics plugin first to track all requests + await app.register(metricsPlugin); + await app.register(prismaPlugin); const apollo = new ApolloServer({ @@ -76,23 +69,28 @@ export async function createApp() { }), }); - await app.register(featureFlagsPlugin, { - provider, - postHog: - provider === featureFlagProviders.post_hog - ? { - apiKey: process.env.POSTHOG_API_KEY!, - host: process.env.POSTHOG_HOST, - } - : undefined, - growthBook: - provider === featureFlagProviders.growth_book - ? { - apiKey: process.env.GROWTHBOOK_API_KEY!, - apiHost: process.env.GROWTHBOOK_API_HOST, - } - : undefined, - }); + // Register feature flags plugin only if provider is configured + if (provider && Object.values(featureFlagProviders).includes(provider)) { + await app.register(featureFlagsPlugin, { + provider, + postHog: + provider === featureFlagProviders.post_hog + ? { + apiKey: process.env.POSTHOG_API_KEY!, + host: process.env.POSTHOG_HOST, + } + : undefined, + growthBook: + provider === featureFlagProviders.growth_book + ? { + apiKey: process.env.GROWTHBOOK_API_KEY!, + apiHost: process.env.GROWTHBOOK_API_HOST, + } + : undefined, + }); + } else { + app.log.warn('Feature flags provider not configured. Skipping feature flags plugin.'); + } await app.ready(); diff --git a/examples/with-graphql-prisma/apps/api/src/plugins/metrics.ts b/examples/with-graphql-prisma/apps/api/src/plugins/metrics.ts new file mode 100644 index 00000000..bf65f708 --- /dev/null +++ b/examples/with-graphql-prisma/apps/api/src/plugins/metrics.ts @@ -0,0 +1,85 @@ +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import { register, Counter, Histogram, collectDefaultMetrics } from 'prom-client'; + +// Create metrics +const httpRequestCounter = new Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], +}); + +const httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.1, 0.5, 1, 2, 5, 10], +}); + +const graphqlRequestCounter = new Counter({ + name: 'graphql_requests_total', + help: 'Total number of GraphQL requests', + labelNames: ['operation_name', 'operation_type'], +}); + +// Collect default metrics (CPU, memory, etc.) +collectDefaultMetrics({ register }); + +const metricsPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { + // Hook to track all HTTP requests + fastify.addHook('onRequest', async (request, reply) => { + // Store start time for duration calculation + request.requestStartTime = Date.now(); + }); + + fastify.addHook('onResponse', async (request, reply) => { + const duration = (Date.now() - (request.requestStartTime || Date.now())) / 1000; + + const route = request.routeOptions?.url || request.url; + const method = request.method; + const statusCode = reply.statusCode.toString(); + + // Increment request counter + httpRequestCounter.inc({ + method, + route, + status_code: statusCode, + }); + + // Record request duration + httpRequestDuration.observe( + { + method, + route, + status_code: statusCode, + }, + duration + ); + }); + + // Endpoint to expose metrics + fastify.get('/metrics', async (request, reply) => { + reply.header('Content-Type', register.contentType); + return register.metrics(); + }); + + // Health check endpoint + fastify.get('/health', async (request, reply) => { + return { status: 'ok', timestamp: new Date().toISOString() }; + }); +}; + +// Extend FastifyRequest type to include requestStartTime +declare module 'fastify' { + interface FastifyRequest { + requestStartTime?: number; + } +} + +export default fp(metricsPlugin, { + name: 'metrics', +}); + +export { httpRequestCounter, httpRequestDuration, graphqlRequestCounter, register }; + + diff --git a/examples/with-graphql-prisma/apps/api/start.sh b/examples/with-graphql-prisma/apps/api/start.sh new file mode 100644 index 00000000..a870d779 --- /dev/null +++ b/examples/with-graphql-prisma/apps/api/start.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# ============================================================================== +# API Container Startup Script +# ============================================================================== +# This script is executed automatically inside the Docker container. +# DO NOT run this script manually outside of Docker. +# +# This script: +# 1. Starts Grafana Alloy agent for observability +# 2. Starts the Node.js API application +# 3. Redirects logs to /tmp/api.log for Alloy to read and send to Loki +# ============================================================================== + +set -e + +# Create log file +touch /tmp/api.log + +echo "=================================" +echo "Starting Grafana Alloy..." +echo "=================================" +/usr/local/bin/alloy run /app/apps/api/alloy.config \ + --server.http.listen-addr=0.0.0.0:12345 \ + --storage.path=/tmp/alloy & + +ALLOY_PID=$! +echo "Alloy started with PID: $ALLOY_PID" +echo "Alloy will read logs from /tmp/api.log and send to Grafana Cloud Loki" + +echo "=================================" +echo "Starting API application..." +echo "=================================" +echo "Logs are being written to /tmp/api.log" +echo "Alloy is sending logs to Grafana Cloud Loki" + +cd /app/apps/api +# Start app and redirect logs to file (Alloy reads from here) and also show in stdout +npm start 2>&1 | tee -a /tmp/api.log + diff --git a/examples/with-graphql-prisma/docker-compose.observability.yml b/examples/with-graphql-prisma/docker-compose.observability.yml new file mode 100644 index 00000000..661c6ed9 --- /dev/null +++ b/examples/with-graphql-prisma/docker-compose.observability.yml @@ -0,0 +1,58 @@ +services: + # ============================================================================ + # API Application with Alloy + # ============================================================================ + api: + # platform: linux/amd64 # Use linux/arm64 for Apple Silicon + build: + context: . + dockerfile: ./apps/api/Dockerfile + args: + APP_PATH: apps/api + PORT: 4000 + ports: + - "4000:4000" # API port + - "12345:12345" # Alloy UI port + environment: + # Application + PORT: 4000 + NODE_ENV: production + LOG_LEVEL: ${LOG_LEVEL:-info} + CORS_ORIGINS: ${CORS_ORIGINS:-["*"]} + + # Alloy - Application Info + APP_NAME: graphql-prisma-api + ENVIRONMENT: ${ENVIRONMENT:-development} + HOSTNAME: ${HOSTNAME:-api-container} + CLUSTER_NAME: ${CLUSTER_NAME:-local-dev} + APP_PORT: 4000 + + # Alloy - Grafana Cloud Loki Configuration + # Get these from: https://grafana.com/docs/grafana-cloud/send-data/logs/logs-loki/ + LOKI_URL: ${LOKI_URL} + LOKI_USERNAME: ${LOKI_USERNAME} + LOKI_PASSWORD: ${LOKI_PASSWORD} + + # Alloy - Grafana Cloud Prometheus Configuration + # Get these from: https://grafana.com/docs/grafana-cloud/send-data/metrics/metrics-prometheus/ + PROMETHEUS_REMOTE_WRITE_URL: ${PROMETHEUS_REMOTE_WRITE_URL} + PROMETHEUS_USERNAME: ${PROMETHEUS_USERNAME} + PROMETHEUS_PASSWORD: ${PROMETHEUS_PASSWORD} + + # Database - SQLite (archivo local) + DATABASE_URL: ${DATABASE_URL:-file:/app/apps/api/prisma/dev.db} + + # Feature Flags (Optional - leave empty to disable) + FEATURE_FLAG_PROVIDER: ${FEATURE_FLAG_PROVIDER:-} + POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + GROWTHBOOK_API_KEY: ${GROWTHBOOK_API_KEY:-} + GROWTHBOOK_API_HOST: ${GROWTHBOOK_API_HOST:-} + volumes: + # Persistir la base de datos SQLite + - sqlite_data:/app/apps/api/prisma + restart: unless-stopped + +volumes: + sqlite_data: + diff --git a/examples/with-graphql-prisma/packages/feature-flags/package.json b/examples/with-graphql-prisma/packages/feature-flags/package.json index 5672e037..f8624f4a 100644 --- a/examples/with-graphql-prisma/packages/feature-flags/package.json +++ b/examples/with-graphql-prisma/packages/feature-flags/package.json @@ -2,20 +2,32 @@ "name": "@repo/feature-flags", "version": "0.0.0", "files": [ - "src/**" + "src/**", + "dist/**" ], "exports": { - "./shared": "./src/shared/index.ts", - "./api": "./src/api/index.ts", - "./web": "./src/web/index.ts" + "./shared": { + "types": "./src/shared/index.ts", + "default": "./dist/shared/index.js" + }, + "./api": { + "types": "./src/api/index.ts", + "default": "./dist/api/index.js" + }, + "./web": { + "types": "./src/web/index.ts", + "import": "./dist/web/index.mjs", + "require": "./dist/web/index.js" + } }, "license": "MIT", "scripts": { - "build": "" + "build": "tsup" }, "devDependencies": { "@repo/typescript-config": "*", "@types/node": "24.1.0", + "@types/react": "^18.3.12", "tsup": "8.5.0", "typescript": "5.8.3" }, @@ -28,4 +40,4 @@ "react": "19.1.1", "next": "15.4.4" } -} +} \ No newline at end of file diff --git a/examples/with-graphql-prisma/packages/utils/tsconfig.json b/examples/with-graphql-prisma/packages/utils/tsconfig.json index 0ea57b5e..804c5cdc 100644 --- a/examples/with-graphql-prisma/packages/utils/tsconfig.json +++ b/examples/with-graphql-prisma/packages/utils/tsconfig.json @@ -2,7 +2,8 @@ "extends": "@repo/typescript-config/library.json", "compilerOptions": { "outDir": "dist", - "removeComments": false + "removeComments": false, + "lib": ["ESNext", "DOM"] }, "include": ["src"], "exclude": ["node_modules", "dist"] From 29f5124e5434814ee5e4617f6928ff4591832a78 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 14 Oct 2025 13:33:15 -0400 Subject: [PATCH 2/5] feat: add alloy integration to with-graphql-mongoose --- .../with-graphql-mongoose/apps/api/Dockerfile | 41 +++++- .../apps/api/alloy.config | 136 ++++++++++++++++++ .../apps/api/package.json | 3 +- .../with-graphql-mongoose/apps/api/src/app.ts | 46 +++--- .../apps/api/src/plugins/metrics.ts | 101 +++++++++++++ .../apps/api/src/server.ts | 1 + .../with-graphql-mongoose/apps/api/start.sh | 39 +++++ .../docker-compose.observability.yml | 70 +++++++++ .../packages/feature-flags/package.json | 24 +++- .../packages/feature-flags/tsup.config.ts | 1 + .../packages/utils/src/safe-fetch.ts | 6 +- 11 files changed, 428 insertions(+), 40 deletions(-) create mode 100644 examples/with-graphql-mongoose/apps/api/alloy.config create mode 100644 examples/with-graphql-mongoose/apps/api/src/plugins/metrics.ts create mode 100644 examples/with-graphql-mongoose/apps/api/start.sh create mode 100644 examples/with-graphql-mongoose/docker-compose.observability.yml diff --git a/examples/with-graphql-mongoose/apps/api/Dockerfile b/examples/with-graphql-mongoose/apps/api/Dockerfile index 2782c80e..eb559716 100644 --- a/examples/with-graphql-mongoose/apps/api/Dockerfile +++ b/examples/with-graphql-mongoose/apps/api/Dockerfile @@ -1,8 +1,7 @@ FROM node:22-alpine AS base ENV SCOPE=@repo/api -ARG APP_PATH -ENV APP_PATH=$APP_PATH +ENV APP_PATH=apps/api ### Builder FROM base AS builder @@ -46,25 +45,55 @@ FROM base AS runner # Environment and dependencies ENV NODE_ENV production -RUN apk add --no-cache tzdata +RUN apk add --no-cache tzdata curl bash unzip libc6-compat libstdc++ RUN apk update ENV TZ=America/Caracas WORKDIR /app +# Install Grafana Alloy (detect architecture) +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + ALLOY_ARCH="amd64"; \ + elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \ + ALLOY_ARCH="arm64"; \ + else \ + echo "Unsupported architecture: $ARCH" && exit 1; \ + fi && \ + curl -L -o /tmp/alloy.zip "https://github.com/grafana/alloy/releases/latest/download/alloy-linux-${ALLOY_ARCH}.zip" && \ + unzip /tmp/alloy.zip -d /tmp && \ + mv /tmp/alloy-linux-${ALLOY_ARCH} /usr/local/bin/alloy && \ + chmod +x /usr/local/bin/alloy && \ + rm /tmp/alloy.zip + # User RUN addgroup --system --gid 1001 avilatek RUN adduser --system --uid 1001 avilatek -USER avilatek -# Copy dist + +# Copy dist and dependencies COPY --from=installer /app/package*.json . +COPY --from=installer /app/node_modules ./node_modules +COPY --from=installer /app/packages ./packages COPY --from=installer /app/${APP_PATH}/dist ./${APP_PATH}/dist COPY --from=installer /app/${APP_PATH}/src/*.env* ./${APP_PATH}/src/*.env* COPY --from=installer /app/${APP_PATH}/package.json ./${APP_PATH}/package.json +# Copy Alloy configuration +COPY --from=builder /app/${APP_PATH}/alloy.config ./${APP_PATH}/alloy.config + +# Copy startup script +COPY --from=builder /app/${APP_PATH}/start.sh /app/start.sh +RUN chmod +x /app/start.sh + + +# Give permissions to alloy storage path and startup script +RUN mkdir -p /tmp/alloy && \ + chown -R avilatek:avilatek /tmp/alloy && \ + chown avilatek:avilatek /app/start.sh + ARG PORT ENV PORT=$PORT EXPOSE $PORT WORKDIR /app/${APP_PATH} -CMD ["npm", "start"] +CMD ["/bin/bash", "/app/start.sh"] diff --git a/examples/with-graphql-mongoose/apps/api/alloy.config b/examples/with-graphql-mongoose/apps/api/alloy.config new file mode 100644 index 00000000..eed5679b --- /dev/null +++ b/examples/with-graphql-mongoose/apps/api/alloy.config @@ -0,0 +1,136 @@ +// Grafana Alloy Configuration for API Application +// This configuration collects logs and metrics from the Node.js API + +// ============================================================================ +// LOGGING PIPELINE +// ============================================================================ + +// Collect logs from the application's log file +loki.source.file "app_logs" { + targets = [ + { + __path__ = "/tmp/api.log", + job = env("APP_NAME"), + app = env("APP_NAME"), + env = env("ENVIRONMENT"), + instance = env("HOSTNAME"), + }, + ] + + forward_to = [loki.process.add_labels.receiver] +} + +// Process and enrich logs +loki.process "add_labels" { + // Add additional labels + stage.static_labels { + values = { + cluster = env("CLUSTER_NAME"), + service = env("APP_NAME"), + } + } + + // Parse JSON logs if they exist + stage.json { + expressions = { + level = "level", + message = "msg", + timestamp = "time", + } + } + + // Extract log level + stage.labels { + values = { + level = "level", + } + } + + forward_to = [loki.write.default.receiver] +} + +// Send logs to Loki +loki.write "default" { + endpoint { + url = env("LOKI_URL") + + // Optional: Add basic auth if needed + basic_auth { + username = env("LOKI_USERNAME") + password = env("LOKI_PASSWORD") + } + } + + external_labels = { + cluster = env("CLUSTER_NAME"), + } +} + +// ============================================================================ +// METRICS PIPELINE +// ============================================================================ + +// Scrape metrics from the application (if it exposes /metrics) +prometheus.scrape "app_metrics" { + targets = [ + { + __address__ = "localhost:" + env("APP_PORT"), + job = env("APP_NAME"), + app = env("APP_NAME"), + env = env("ENVIRONMENT"), + }, + ] + + forward_to = [prometheus.relabel.add_labels.receiver] + scrape_interval = "15s" + metrics_path = "/metrics" +} + +// Add labels to metrics +prometheus.relabel "add_labels" { + rule { + target_label = "cluster" + replacement = env("CLUSTER_NAME") + } + + rule { + target_label = "instance" + replacement = env("HOSTNAME") + } + + forward_to = [prometheus.remote_write.default.receiver] +} + +// Send metrics to Prometheus/Mimir +prometheus.remote_write "default" { + endpoint { + url = env("PROMETHEUS_REMOTE_WRITE_URL") + + // Optional: Add basic auth if needed + basic_auth { + username = env("PROMETHEUS_USERNAME") + password = env("PROMETHEUS_PASSWORD") + } + } + + external_labels = { + cluster = env("CLUSTER_NAME"), + env = env("ENVIRONMENT"), + } +} + +// ============================================================================ +// PROCESS METRICS (CPU, Memory, etc.) +// ============================================================================ + +// Collect host/container metrics +prometheus.exporter.unix "node" { + include_exporter_metrics = true +} + +prometheus.scrape "node_metrics" { + targets = prometheus.exporter.unix.node.targets + forward_to = [prometheus.remote_write.default.receiver] + job_name = "node-exporter" +} + diff --git a/examples/with-graphql-mongoose/apps/api/package.json b/examples/with-graphql-mongoose/apps/api/package.json index 81e80095..f9ce1e18 100644 --- a/examples/with-graphql-mongoose/apps/api/package.json +++ b/examples/with-graphql-mongoose/apps/api/package.json @@ -53,9 +53,10 @@ "pino-pretty": "13.0.0", "pino-loki": "2.6.0", "postmark": "4.0.5", + "prom-client": "^15.1.3", "@repo/feature-flags": "*", "slug": "11.0.0", "ts-node": "10.9.2", "zod": "4.0.5" } -} +} \ No newline at end of file diff --git a/examples/with-graphql-mongoose/apps/api/src/app.ts b/examples/with-graphql-mongoose/apps/api/src/app.ts index 4b710fe8..12f82cb5 100644 --- a/examples/with-graphql-mongoose/apps/api/src/app.ts +++ b/examples/with-graphql-mongoose/apps/api/src/app.ts @@ -16,6 +16,7 @@ import * as Sentry from '@sentry/node'; import Fastify, { FastifyHttpOptions } from 'fastify'; import mongoose from 'mongoose'; import featureFlagsPlugin from './plugins/feature-flags'; +import metricsPlugin from './plugins/metrics'; import { TFeatureFlagProvider, featureFlagProviders, @@ -47,13 +48,7 @@ export async function createApp() { config = { logger: { level: 'info', - transport: { - target: '@axiomhq/pino', - options: { - dataset: process.env.AXIOM_DATASET, - token: process.env.AXIOM_TOKEN, - }, - }, + }, }; } @@ -91,23 +86,26 @@ export async function createApp() { }), }); - await app.register(featureFlagsPlugin, { - provider, - postHog: - provider === featureFlagProviders.post_hog - ? { - apiKey: process.env.POSTHOG_API_KEY!, - host: process.env.POSTHOG_HOST, - } - : undefined, - growthBook: - provider === featureFlagProviders.growth_book - ? { - apiKey: process.env.GROWTHBOOK_API_KEY!, - apiHost: process.env.GROWTHBOOK_API_HOST, - } - : undefined, - }); + await app.register(metricsPlugin); + if (process.env.FEATURE_FLAG_PROVIDER) { + await app.register(featureFlagsPlugin, { + provider, + postHog: + provider === featureFlagProviders.post_hog + ? { + apiKey: process.env.POSTHOG_API_KEY!, + host: process.env.POSTHOG_HOST, + } + : undefined, + growthBook: + provider === featureFlagProviders.growth_book + ? { + apiKey: process.env.GROWTHBOOK_API_KEY!, + apiHost: process.env.GROWTHBOOK_API_HOST, + } + : undefined, + }); + } await app.ready(); diff --git a/examples/with-graphql-mongoose/apps/api/src/plugins/metrics.ts b/examples/with-graphql-mongoose/apps/api/src/plugins/metrics.ts new file mode 100644 index 00000000..fbb4c5da --- /dev/null +++ b/examples/with-graphql-mongoose/apps/api/src/plugins/metrics.ts @@ -0,0 +1,101 @@ +import { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import { register, collectDefaultMetrics, Counter, Histogram, Gauge } from 'prom-client'; + +// Enable default metrics collection +collectDefaultMetrics(); + +// Custom metrics +const httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], +}); + +const httpRequestTotal = new Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], +}); + +const activeConnections = new Gauge({ + name: 'active_connections', + help: 'Number of active connections', +}); + +const graphqlOperations = new Counter({ + name: 'graphql_operations_total', + help: 'Total number of GraphQL operations', + labelNames: ['operation_type', 'operation_name'], +}); + +const graphqlOperationDuration = new Histogram({ + name: 'graphql_operation_duration_seconds', + help: 'Duration of GraphQL operations in seconds', + labelNames: ['operation_type', 'operation_name'], + buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], +}); + +// Export metrics for use in other parts of the application +export { + httpRequestDuration, + httpRequestTotal, + activeConnections, + graphqlOperations, + graphqlOperationDuration, +}; + +export default async function metricsPlugin( + fastify: FastifyInstance, + options: FastifyPluginOptions +) { + // Metrics endpoint + fastify.get('/metrics', async (request, reply) => { + reply.type('text/plain'); + return register.metrics(); + }); + + // Health check endpoint + fastify.get('/health', async (request, reply) => { + return { status: 'ok', timestamp: new Date().toISOString() }; + }); + + // Add request logging middleware + fastify.addHook('onRequest', async (request, reply) => { + request.startTime = Date.now(); + }); + + fastify.addHook('onResponse', async (request, reply) => { + const duration = (Date.now() - request.startTime) / 1000; + const route = request.routerPath || request.url; + + httpRequestDuration + .labels({ + method: request.method, + route, + status_code: reply.statusCode.toString(), + }) + .observe(duration); + + httpRequestTotal + .labels({ + method: request.method, + route, + status_code: reply.statusCode.toString(), + }) + .inc(); + }); + + // Track active connections + fastify.addHook('onReady', async () => { + activeConnections.set(0); + }); + + fastify.addHook('onRequest', async () => { + activeConnections.inc(); + }); + + fastify.addHook('onResponse', async () => { + activeConnections.dec(); + }); +} diff --git a/examples/with-graphql-mongoose/apps/api/src/server.ts b/examples/with-graphql-mongoose/apps/api/src/server.ts index ec3e04a3..3f0e6290 100644 --- a/examples/with-graphql-mongoose/apps/api/src/server.ts +++ b/examples/with-graphql-mongoose/apps/api/src/server.ts @@ -10,6 +10,7 @@ export async function start() { const port = parseInt(String(process.env.PORT || '3000'), 10); const host = process.env.HOST || '0.0.0.0'; + console.log(`Server running on ${host}:${port}`); await server.listen({ host, port }); for (const signal of ['SIGINT', 'SIGTERM']) { diff --git a/examples/with-graphql-mongoose/apps/api/start.sh b/examples/with-graphql-mongoose/apps/api/start.sh new file mode 100644 index 00000000..a870d779 --- /dev/null +++ b/examples/with-graphql-mongoose/apps/api/start.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# ============================================================================== +# API Container Startup Script +# ============================================================================== +# This script is executed automatically inside the Docker container. +# DO NOT run this script manually outside of Docker. +# +# This script: +# 1. Starts Grafana Alloy agent for observability +# 2. Starts the Node.js API application +# 3. Redirects logs to /tmp/api.log for Alloy to read and send to Loki +# ============================================================================== + +set -e + +# Create log file +touch /tmp/api.log + +echo "=================================" +echo "Starting Grafana Alloy..." +echo "=================================" +/usr/local/bin/alloy run /app/apps/api/alloy.config \ + --server.http.listen-addr=0.0.0.0:12345 \ + --storage.path=/tmp/alloy & + +ALLOY_PID=$! +echo "Alloy started with PID: $ALLOY_PID" +echo "Alloy will read logs from /tmp/api.log and send to Grafana Cloud Loki" + +echo "=================================" +echo "Starting API application..." +echo "=================================" +echo "Logs are being written to /tmp/api.log" +echo "Alloy is sending logs to Grafana Cloud Loki" + +cd /app/apps/api +# Start app and redirect logs to file (Alloy reads from here) and also show in stdout +npm start 2>&1 | tee -a /tmp/api.log + diff --git a/examples/with-graphql-mongoose/docker-compose.observability.yml b/examples/with-graphql-mongoose/docker-compose.observability.yml new file mode 100644 index 00000000..74d57f56 --- /dev/null +++ b/examples/with-graphql-mongoose/docker-compose.observability.yml @@ -0,0 +1,70 @@ +services: + # ============================================================================ + # API Application with Alloy + # ============================================================================ + api: + # platform: linux/amd64 # Use linux/arm64 for Apple Silicon + build: + context: . + dockerfile: ./apps/api/Dockerfile + args: + APP_PATH: apps/api + PORT: 4000 + ports: + - "4000:4000" + environment: + # Application + PORT: 4000 + NODE_ENV: production + LOG_LEVEL: ${LOG_LEVEL:-info} + CORS_ORIGINS: ${CORS_ORIGINS:-["*"]} + + # Alloy - Application Info + APP_NAME: graphql-mongoose-api + ENVIRONMENT: ${ENVIRONMENT:-development} + HOSTNAME: ${HOSTNAME:-api-container} + CLUSTER_NAME: ${CLUSTER_NAME:-local-dev} + APP_PORT: 4000 + + # Alloy - Grafana Cloud Loki Configuration + # Get these from: https://grafana.com/docs/grafana-cloud/send-data/logs/logs-loki/ + LOKI_URL: ${LOKI_URL} + LOKI_USERNAME: ${LOKI_USERNAME} + LOKI_PASSWORD: ${LOKI_PASSWORD} + + # Alloy - Grafana Cloud Prometheus Configuration + # Get these from: https://grafana.com/docs/grafana-cloud/send-data/metrics/metrics-prometheus/ + PROMETHEUS_REMOTE_WRITE_URL: ${PROMETHEUS_REMOTE_WRITE_URL} + PROMETHEUS_USERNAME: ${PROMETHEUS_USERNAME} + PROMETHEUS_PASSWORD: ${PROMETHEUS_PASSWORD} + + # Database - MongoDB + DATABASE: ${DATABASE:-mongodb://mongo:27017/graphql-mongoose} + + # Feature Flags (Optional - leave empty to disable) + FEATURE_FLAG_PROVIDER: ${FEATURE_FLAG_PROVIDER:-} + POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + GROWTHBOOK_API_KEY: ${GROWTHBOOK_API_KEY:-} + GROWTHBOOK_API_HOST: ${GROWTHBOOK_API_HOST:-} + depends_on: + - mongo + restart: unless-stopped + + # ============================================================================ + # MongoDB Database + # ============================================================================ + mongo: + image: mongo:7.0 + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USERNAME:-admin} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-password} + MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-graphql-mongoose} + volumes: + - mongo_data:/data/db + restart: unless-stopped + +volumes: + mongo_data: diff --git a/examples/with-graphql-mongoose/packages/feature-flags/package.json b/examples/with-graphql-mongoose/packages/feature-flags/package.json index 5672e037..f8624f4a 100644 --- a/examples/with-graphql-mongoose/packages/feature-flags/package.json +++ b/examples/with-graphql-mongoose/packages/feature-flags/package.json @@ -2,20 +2,32 @@ "name": "@repo/feature-flags", "version": "0.0.0", "files": [ - "src/**" + "src/**", + "dist/**" ], "exports": { - "./shared": "./src/shared/index.ts", - "./api": "./src/api/index.ts", - "./web": "./src/web/index.ts" + "./shared": { + "types": "./src/shared/index.ts", + "default": "./dist/shared/index.js" + }, + "./api": { + "types": "./src/api/index.ts", + "default": "./dist/api/index.js" + }, + "./web": { + "types": "./src/web/index.ts", + "import": "./dist/web/index.mjs", + "require": "./dist/web/index.js" + } }, "license": "MIT", "scripts": { - "build": "" + "build": "tsup" }, "devDependencies": { "@repo/typescript-config": "*", "@types/node": "24.1.0", + "@types/react": "^18.3.12", "tsup": "8.5.0", "typescript": "5.8.3" }, @@ -28,4 +40,4 @@ "react": "19.1.1", "next": "15.4.4" } -} +} \ No newline at end of file diff --git a/examples/with-graphql-mongoose/packages/feature-flags/tsup.config.ts b/examples/with-graphql-mongoose/packages/feature-flags/tsup.config.ts index d857ce89..306b5c56 100644 --- a/examples/with-graphql-mongoose/packages/feature-flags/tsup.config.ts +++ b/examples/with-graphql-mongoose/packages/feature-flags/tsup.config.ts @@ -13,6 +13,7 @@ export default defineConfig([ splitting: false, clean: true, sourcemap: true, + dts: true, target: 'node20', shims: true, bundle: true, diff --git a/examples/with-graphql-mongoose/packages/utils/src/safe-fetch.ts b/examples/with-graphql-mongoose/packages/utils/src/safe-fetch.ts index 214cebea..9472f270 100644 --- a/examples/with-graphql-mongoose/packages/utils/src/safe-fetch.ts +++ b/examples/with-graphql-mongoose/packages/utils/src/safe-fetch.ts @@ -5,8 +5,8 @@ import { safe } from './safe-functions'; * @description Wraps the `fetch` function with error handling using the `safe` utility. * Returns a `Safe` type object containing the JSON response or an error message. * - * @param {RequestInfo | URL} input - The input to fetch, similar to the `fetch` API. - * @param {RequestInit} [init] - Optional settings to apply to the request. + * @param {string | URL} input - The input to fetch, similar to the `fetch` API. + * @param {any} [init] - Optional settings to apply to the request. * * @returns {Promise>} - A `Safe` object containing the JSON-parsed data if successful, * or an error message if an error occurs. @@ -23,7 +23,7 @@ import { safe } from './safe-functions'; * }; * ``` */ -export async function safeFetch(input: RequestInfo | URL, init?: RequestInit) { +export async function safeFetch(input: string | URL, init?: any) { const response = await safe(fetch(input, init)); if (response.success) { const jsonResponse = await safe(response.data.json()); From 74871d574edc3dd6c340ad74fac646603b6ca247 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 23 Oct 2025 11:19:48 -0400 Subject: [PATCH 3/5] feat: add aloy integration restfull mongoose --- .../apps/api/src/plugins/metrics.ts | 134 ++++++++--------- .../with-restful-mongoose/apps/api/Dockerfile | 45 +++++- .../apps/api/alloy.config | 135 ++++++++++++++++++ .../apps/api/package.json | 3 +- .../with-restful-mongoose/apps/api/src/app.ts | 19 +-- .../api/src/components/user/user.model.ts | 5 +- .../apps/api/src/instrument.ts | 30 +--- .../src/plugins/integrations/feature-flags.ts | 6 + .../api/src/plugins/integrations/metrics.ts | 83 +++++++++++ .../with-restful-mongoose/apps/api/start.sh | 38 +++++ .../apps/api/tsup.config.ts | 18 +-- .../docker-compose.observability.yml | 74 ++++++++++ .../packages/feature-flags/package.json | 24 +++- .../packages/feature-flags/tsup.config.ts | 1 + .../packages/utils/src/safe-fetch.ts | 6 +- 15 files changed, 478 insertions(+), 143 deletions(-) create mode 100644 examples/with-restful-mongoose/apps/api/alloy.config create mode 100644 examples/with-restful-mongoose/apps/api/src/plugins/integrations/metrics.ts create mode 100644 examples/with-restful-mongoose/apps/api/start.sh create mode 100644 examples/with-restful-mongoose/docker-compose.observability.yml diff --git a/examples/with-graphql-mongoose/apps/api/src/plugins/metrics.ts b/examples/with-graphql-mongoose/apps/api/src/plugins/metrics.ts index fbb4c5da..d248181e 100644 --- a/examples/with-graphql-mongoose/apps/api/src/plugins/metrics.ts +++ b/examples/with-graphql-mongoose/apps/api/src/plugins/metrics.ts @@ -1,101 +1,83 @@ -import { FastifyInstance, FastifyPluginOptions } from 'fastify'; -import { register, collectDefaultMetrics, Counter, Histogram, Gauge } from 'prom-client'; +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import { register, Counter, Histogram, collectDefaultMetrics } from 'prom-client'; -// Enable default metrics collection -collectDefaultMetrics(); - -// Custom metrics -const httpRequestDuration = new Histogram({ - name: 'http_request_duration_seconds', - help: 'Duration of HTTP requests in seconds', - labelNames: ['method', 'route', 'status_code'], - buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], -}); - -const httpRequestTotal = new Counter({ +// Create metrics +const httpRequestCounter = new Counter({ name: 'http_requests_total', help: 'Total number of HTTP requests', labelNames: ['method', 'route', 'status_code'], }); -const activeConnections = new Gauge({ - name: 'active_connections', - help: 'Number of active connections', -}); - -const graphqlOperations = new Counter({ - name: 'graphql_operations_total', - help: 'Total number of GraphQL operations', - labelNames: ['operation_type', 'operation_name'], +const httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.1, 0.5, 1, 2, 5, 10], }); -const graphqlOperationDuration = new Histogram({ - name: 'graphql_operation_duration_seconds', - help: 'Duration of GraphQL operations in seconds', - labelNames: ['operation_type', 'operation_name'], - buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], +const graphqlRequestCounter = new Counter({ + name: 'graphql_requests_total', + help: 'Total number of GraphQL requests', + labelNames: ['operation_name', 'operation_type'], }); -// Export metrics for use in other parts of the application -export { - httpRequestDuration, - httpRequestTotal, - activeConnections, - graphqlOperations, - graphqlOperationDuration, -}; - -export default async function metricsPlugin( - fastify: FastifyInstance, - options: FastifyPluginOptions -) { - // Metrics endpoint - fastify.get('/metrics', async (request, reply) => { - reply.type('text/plain'); - return register.metrics(); - }); +// Collect default metrics (CPU, memory, etc.) +collectDefaultMetrics({ register }); - // Health check endpoint - fastify.get('/health', async (request, reply) => { - return { status: 'ok', timestamp: new Date().toISOString() }; - }); - - // Add request logging middleware +const metricsPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { + // Hook to track all HTTP requests fastify.addHook('onRequest', async (request, reply) => { - request.startTime = Date.now(); + // Store start time for duration calculation + request.requestStartTime = Date.now(); }); fastify.addHook('onResponse', async (request, reply) => { - const duration = (Date.now() - request.startTime) / 1000; - const route = request.routerPath || request.url; + const duration = (Date.now() - (request.requestStartTime || Date.now())) / 1000; - httpRequestDuration - .labels({ - method: request.method, - route, - status_code: reply.statusCode.toString(), - }) - .observe(duration); + const route = request.routeOptions?.url || request.url; + const method = request.method; + const statusCode = reply.statusCode.toString(); + + // Increment request counter + httpRequestCounter.inc({ + method, + route, + status_code: statusCode, + }); - httpRequestTotal - .labels({ - method: request.method, + // Record request duration + httpRequestDuration.observe( + { + method, route, - status_code: reply.statusCode.toString(), - }) - .inc(); + status_code: statusCode, + }, + duration + ); }); - // Track active connections - fastify.addHook('onReady', async () => { - activeConnections.set(0); + // Endpoint to expose metrics + fastify.get('/metrics', async (request, reply) => { + reply.header('Content-Type', register.contentType); + return register.metrics(); }); - fastify.addHook('onRequest', async () => { - activeConnections.inc(); + // Health check endpoint + fastify.get('/health', async (request, reply) => { + return { status: 'ok', timestamp: new Date().toISOString() }; }); +}; - fastify.addHook('onResponse', async () => { - activeConnections.dec(); - }); +// Extend FastifyRequest type to include requestStartTime +declare module 'fastify' { + interface FastifyRequest { + requestStartTime?: number; + } } + +export default fp(metricsPlugin, { + name: 'metrics', +}); + +export { httpRequestCounter, httpRequestDuration, graphqlRequestCounter, register }; diff --git a/examples/with-restful-mongoose/apps/api/Dockerfile b/examples/with-restful-mongoose/apps/api/Dockerfile index 485bd6aa..fbf0ecd0 100644 --- a/examples/with-restful-mongoose/apps/api/Dockerfile +++ b/examples/with-restful-mongoose/apps/api/Dockerfile @@ -1,8 +1,7 @@ FROM node:24-alpine AS base ENV SCOPE=@repo/api -ARG APP_PATH -ENV APP_PATH=$APP_PATH +ENV APP_PATH=apps/api ### Builder FROM base AS builder @@ -46,25 +45,59 @@ FROM base AS runner # Environment and dependencies ENV NODE_ENV production -RUN apk add --no-cache tzdata +RUN apk add --no-cache tzdata curl bash unzip libc6-compat libstdc++ RUN apk update ENV TZ=America/Caracas WORKDIR /app +# Install Grafana Alloy (detect architecture) +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + ALLOY_ARCH="amd64"; \ + elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \ + ALLOY_ARCH="arm64"; \ + else \ + echo "Unsupported architecture: $ARCH" && exit 1; \ + fi && \ + curl -L -o /tmp/alloy.zip "https://github.com/grafana/alloy/releases/latest/download/alloy-linux-${ALLOY_ARCH}.zip" && \ + unzip /tmp/alloy.zip -d /tmp && \ + mv /tmp/alloy-linux-${ALLOY_ARCH} /usr/local/bin/alloy && \ + chmod +x /usr/local/bin/alloy && \ + rm /tmp/alloy.zip + # User RUN addgroup --system --gid 1001 avilatek RUN adduser --system --uid 1001 avilatek -USER avilatek -# Copy dist + +# Copy dist and dependencies COPY --from=installer /app/package*.json . +COPY --from=installer /app/node_modules ./node_modules +COPY --from=installer /app/packages ./packages COPY --from=installer /app/${APP_PATH}/dist ./${APP_PATH}/dist COPY --from=installer /app/${APP_PATH}/src/*.env* ./${APP_PATH}/src/*.env* COPY --from=installer /app/${APP_PATH}/package.json ./${APP_PATH}/package.json +# Ensure all Sentry dependencies are available +RUN ls -la /app/node_modules/@sentry/ || echo "Sentry modules not found" + +# Copy Alloy configuration +COPY --from=builder /app/${APP_PATH}/alloy.config ./${APP_PATH}/alloy.config + +# Copy startup script +COPY --from=builder /app/${APP_PATH}/start.sh /app/start.sh +RUN chmod +x /app/start.sh + +# Give permissions to alloy storage path and startup script +RUN mkdir -p /tmp/alloy && \ + chown -R avilatek:avilatek /tmp/alloy && \ + chown avilatek:avilatek /app/start.sh + +USER avilatek + ARG PORT ENV PORT=$PORT EXPOSE $PORT WORKDIR /app/${APP_PATH} -CMD ["npm", "start"] +CMD ["/bin/bash", "/app/start.sh"] diff --git a/examples/with-restful-mongoose/apps/api/alloy.config b/examples/with-restful-mongoose/apps/api/alloy.config new file mode 100644 index 00000000..e1956daa --- /dev/null +++ b/examples/with-restful-mongoose/apps/api/alloy.config @@ -0,0 +1,135 @@ +// Grafana Alloy Configuration for API Application +// This configuration collects logs and metrics from the Node.js API + +// ============================================================================ +// LOGGING PIPELINE +// ============================================================================ + +// Collect logs from the application's log file +loki.source.file "app_logs" { + targets = [ + { + __path__ = "/tmp/api.log", + job = env("APP_NAME"), + app = env("APP_NAME"), + env = env("ENVIRONMENT"), + instance = env("HOSTNAME"), + }, + ] + + forward_to = [loki.process.add_labels.receiver] +} + +// Process and enrich logs +loki.process "add_labels" { + // Add additional labels + stage.static_labels { + values = { + cluster = env("CLUSTER_NAME"), + service = env("APP_NAME"), + } + } + + // Parse JSON logs if they exist + stage.json { + expressions = { + level = "level", + message = "msg", + timestamp = "time", + } + } + + // Extract log level + stage.labels { + values = { + level = "level", + } + } + + forward_to = [loki.write.default.receiver] +} + +// Send logs to Loki +loki.write "default" { + endpoint { + url = env("LOKI_URL") + + // Optional: Add basic auth if needed + basic_auth { + username = env("LOKI_USERNAME") + password = env("LOKI_PASSWORD") + } + } + + external_labels = { + cluster = env("CLUSTER_NAME"), + } +} + +// ============================================================================ +// METRICS PIPELINE +// ============================================================================ + +// Scrape metrics from the application (if it exposes /metrics) +prometheus.scrape "app_metrics" { + targets = [ + { + __address__ = "localhost:" + env("APP_PORT"), + job = env("APP_NAME"), + app = env("APP_NAME"), + env = env("ENVIRONMENT"), + }, + ] + + forward_to = [prometheus.relabel.add_labels.receiver] + scrape_interval = "15s" + metrics_path = "/metrics" +} + +// Add labels to metrics +prometheus.relabel "add_labels" { + rule { + target_label = "cluster" + replacement = env("CLUSTER_NAME") + } + + rule { + target_label = "instance" + replacement = env("HOSTNAME") + } + + forward_to = [prometheus.remote_write.default.receiver] +} + +// Send metrics to Prometheus/Mimir +prometheus.remote_write "default" { + endpoint { + url = env("PROMETHEUS_REMOTE_WRITE_URL") + + // Optional: Add basic auth if needed + basic_auth { + username = env("PROMETHEUS_USERNAME") + password = env("PROMETHEUS_PASSWORD") + } + } + + external_labels = { + cluster = env("CLUSTER_NAME"), + env = env("ENVIRONMENT"), + } +} + +// ============================================================================ +// PROCESS METRICS (CPU, Memory, etc.) +// ============================================================================ + +// Collect host/container metrics +prometheus.exporter.unix "node" { + include_exporter_metrics = true +} + +prometheus.scrape "node_metrics" { + targets = prometheus.exporter.unix.node.targets + forward_to = [prometheus.remote_write.default.receiver] + job_name = "node-exporter" +} diff --git a/examples/with-restful-mongoose/apps/api/package.json b/examples/with-restful-mongoose/apps/api/package.json index c8b1892d..5991af3c 100644 --- a/examples/with-restful-mongoose/apps/api/package.json +++ b/examples/with-restful-mongoose/apps/api/package.json @@ -58,7 +58,8 @@ "pino-loki": "2.6.0", "pino-pretty": "13.1.1", "postmark": "4.0.5", + "prom-client": "^15.1.3", "slug": "11.0.0", "zod": "4.1.1" } -} +} \ No newline at end of file diff --git a/examples/with-restful-mongoose/apps/api/src/app.ts b/examples/with-restful-mongoose/apps/api/src/app.ts index b1f5d4af..cd5dcb95 100644 --- a/examples/with-restful-mongoose/apps/api/src/app.ts +++ b/examples/with-restful-mongoose/apps/api/src/app.ts @@ -15,21 +15,6 @@ export async function createApp() { config = { logger: { level: envs.loki.level || 'info', - transport: { - target: 'pino-loki', - options: { - batching: true, - interval: 5, - labels: { - app: envs.loki.appName, - }, - host: envs.loki.host, - basicAuth: { - username: envs.loki.username, - password: envs.loki.password, - }, - }, - }, }, }; } @@ -52,10 +37,14 @@ export async function createApp() { await app.register(autoload, { dir: path.join(__dirname, 'plugins/middlewares'), }); + + // Load integrations plugins await app.register(autoload, { dir: path.join(__dirname, 'plugins/integrations'), }); + app.log.info('All plugins registered successfully'); + // Register Routes and Websockets await app.register(autoload, { dir: path.join(__dirname, 'plugins/routes'), diff --git a/examples/with-restful-mongoose/apps/api/src/components/user/user.model.ts b/examples/with-restful-mongoose/apps/api/src/components/user/user.model.ts index f7183937..0185d3a0 100644 --- a/examples/with-restful-mongoose/apps/api/src/components/user/user.model.ts +++ b/examples/with-restful-mongoose/apps/api/src/components/user/user.model.ts @@ -1,5 +1,5 @@ import { TUser } from '@repo/schemas'; -import { Document, model, Schema, Types } from 'mongoose'; +import { Document, model, models, Schema, Types } from 'mongoose'; export type TUserDocument = Document< Types.ObjectId, @@ -35,4 +35,5 @@ const userSchema = new Schema( { timestamps: true } ); -export const User = model('User', userSchema); +// Check if model already exists to avoid OverwriteModelError +export const User = models.User || model('User', userSchema); diff --git a/examples/with-restful-mongoose/apps/api/src/instrument.ts b/examples/with-restful-mongoose/apps/api/src/instrument.ts index 599375ce..248033da 100644 --- a/examples/with-restful-mongoose/apps/api/src/instrument.ts +++ b/examples/with-restful-mongoose/apps/api/src/instrument.ts @@ -1,27 +1,5 @@ -import * as Sentry from '@sentry/node'; -import { nodeProfilingIntegration } from '@sentry/profiling-node'; -import { envs } from './config'; +// Sentry is disabled in Docker containers to avoid module resolution issues +// If you need Sentry in production, ensure all dependencies are properly copied +// and the SENTRY_DSN environment variable is set -// Ensure to call this before importing any other modules! -Sentry.init({ - dsn: envs.sentry.dsn, - integrations: [ - // Add our Profiling integration - nodeProfilingIntegration(), - Sentry.rewriteFramesIntegration({ root: process.cwd() || __dirname }), - Sentry.fastifyIntegration(), - Sentry.httpIntegration(), - Sentry.mongooseIntegration(), - ], - - // Add Tracing by setting tracesSampleRate - // We recommend adjusting this value in production - tracesSampleRate: 0.2, - - // Set sampling rate for profiling - // This is relative to tracesSampleRate - profilesSampleRate: 0.2, - sendDefaultPii: true, - enableLogs: false, - enabled: envs.stage === 'production', -}); +console.log('Sentry instrumentation disabled for Docker container'); diff --git a/examples/with-restful-mongoose/apps/api/src/plugins/integrations/feature-flags.ts b/examples/with-restful-mongoose/apps/api/src/plugins/integrations/feature-flags.ts index a64237fb..f6d7539d 100644 --- a/examples/with-restful-mongoose/apps/api/src/plugins/integrations/feature-flags.ts +++ b/examples/with-restful-mongoose/apps/api/src/plugins/integrations/feature-flags.ts @@ -34,6 +34,12 @@ const featureFlagsPlugin: FastifyPluginAsync< FeatureFlagsPluginOptions > = async (fastify, options) => { try { + // Only initialize feature flags if PostHog API key is provided + if (!envs.posthog.apiKey) { + fastify.log.info('PostHog API key not provided, skipping feature flags plugin'); + return; + } + const config: FeatureFlagConfig = { provider: options.provider, postHog: options.postHog, diff --git a/examples/with-restful-mongoose/apps/api/src/plugins/integrations/metrics.ts b/examples/with-restful-mongoose/apps/api/src/plugins/integrations/metrics.ts new file mode 100644 index 00000000..6ca8cb6d --- /dev/null +++ b/examples/with-restful-mongoose/apps/api/src/plugins/integrations/metrics.ts @@ -0,0 +1,83 @@ +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import { register, Counter, Histogram, collectDefaultMetrics } from 'prom-client'; + +// Create metrics +const httpRequestCounter = new Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], +}); + +const httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.1, 0.5, 1, 2, 5, 10], +}); + +const restApiRequestCounter = new Counter({ + name: 'rest_api_requests_total', + help: 'Total number of REST API requests', + labelNames: ['operation_name', 'operation_type'], +}); + +// Collect default metrics (CPU, memory, etc.) +collectDefaultMetrics({ register }); + +const metricsPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { + // Hook to track all HTTP requests + fastify.addHook('onRequest', async (request, reply) => { + // Store start time for duration calculation + request.requestStartTime = Date.now(); + }); + + fastify.addHook('onResponse', async (request, reply) => { + const duration = (Date.now() - (request.requestStartTime || Date.now())) / 1000; + + const route = request.routeOptions?.url || request.url; + const method = request.method; + const statusCode = reply.statusCode.toString(); + + // Increment request counter + httpRequestCounter.inc({ + method, + route, + status_code: statusCode, + }); + + // Record request duration + httpRequestDuration.observe( + { + method, + route, + status_code: statusCode, + }, + duration + ); + }); + + // Endpoint to expose metrics + fastify.get('/metrics', async (request, reply) => { + reply.header('Content-Type', register.contentType); + return register.metrics(); + }); + + // Health check endpoint + fastify.get('/health', async (request, reply) => { + return { status: 'ok', timestamp: new Date().toISOString() }; + }); +}; + +// Extend FastifyRequest type to include requestStartTime +declare module 'fastify' { + interface FastifyRequest { + requestStartTime?: number; + } +} + +export default fp(metricsPlugin, { + name: 'metrics', +}); + +export { httpRequestCounter, httpRequestDuration, restApiRequestCounter, register }; diff --git a/examples/with-restful-mongoose/apps/api/start.sh b/examples/with-restful-mongoose/apps/api/start.sh new file mode 100644 index 00000000..d73f94e6 --- /dev/null +++ b/examples/with-restful-mongoose/apps/api/start.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# ============================================================================== +# API Container Startup Script +# ============================================================================== +# This script is executed automatically inside the Docker container. +# DO NOT run this script manually outside of Docker. +# +# This script: +# 1. Starts Grafana Alloy agent for observability +# 2. Starts the Node.js API application +# 3. Redirects logs to /tmp/api.log for Alloy to read and send to Loki +# ============================================================================== + +set -e + +# Create log file +touch /tmp/api.log + +echo "=================================" +echo "Starting Grafana Alloy..." +echo "=================================" +/usr/local/bin/alloy run /app/apps/api/alloy.config \ + --server.http.listen-addr=0.0.0.0:12345 \ + --storage.path=/tmp/alloy & + +ALLOY_PID=$! +echo "Alloy started with PID: $ALLOY_PID" +echo "Alloy will read logs from /tmp/api.log and send to Grafana Cloud Loki" + +echo "=================================" +echo "Starting API application..." +echo "=================================" +echo "Logs are being written to /tmp/api.log" +echo "Alloy is sending logs to Grafana Cloud Loki" + +cd /app/apps/api +# Start app and redirect logs to file (Alloy reads from here) and also show in stdout +npm start 2>&1 | tee -a /tmp/api.log diff --git a/examples/with-restful-mongoose/apps/api/tsup.config.ts b/examples/with-restful-mongoose/apps/api/tsup.config.ts index e6465592..9d8b673d 100644 --- a/examples/with-restful-mongoose/apps/api/tsup.config.ts +++ b/examples/with-restful-mongoose/apps/api/tsup.config.ts @@ -1,5 +1,5 @@ import 'dotenv/config'; -import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin'; +// import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin'; import { defineConfig } from 'tsup'; export default defineConfig({ @@ -15,11 +15,13 @@ export default defineConfig({ bundle: true, dts: false, skipNodeModulesBundle: true, - esbuildPlugins: [ - sentryEsbuildPlugin({ - org: 'avilatek', - project: '', - authToken: process.env.SENTRY_AUTH_TOKEN, - }), - ], + // Temporarily disabled Sentry esbuild plugin to avoid module resolution issues in Docker + // The plugin injects code that requires modules not available in the container + // esbuildPlugins: [ + // sentryEsbuildPlugin({ + // org: 'avilatek', + // project: '', + // authToken: process.env.SENTRY_AUTH_TOKEN, + // }), + // ], }); diff --git a/examples/with-restful-mongoose/docker-compose.observability.yml b/examples/with-restful-mongoose/docker-compose.observability.yml new file mode 100644 index 00000000..34826e6c --- /dev/null +++ b/examples/with-restful-mongoose/docker-compose.observability.yml @@ -0,0 +1,74 @@ +services: + # ============================================================================ + # API Application with Alloy + # ============================================================================ + api: + # platform: linux/amd64 # Use linux/arm64 for Apple Silicon + build: + context: . + dockerfile: ./apps/api/Dockerfile + args: + APP_PATH: apps/api + PORT: 4000 + ports: + - "4000:4000" + environment: + # Application + PORT: 4000 + NODE_ENV: production + LOG_LEVEL: ${LOG_LEVEL:-info} + CORS_ORIGINS: ${CORS_ORIGINS:-["*"]} + HOST: 0.0.0.0 + JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-here} + POSTMARK_API_KEY: ${POSTMARK_API_KEY:-your-postmark-key} + SENTRY_DSN: ${SENTRY_DSN:-} + + # Alloy - Application Info + APP_NAME: restful-mongoose-api + ENVIRONMENT: ${ENVIRONMENT:-development} + HOSTNAME: ${HOSTNAME:-api-container} + CLUSTER_NAME: ${CLUSTER_NAME:-local-dev} + APP_PORT: 4000 + + # Alloy - Grafana Cloud Loki Configuration + # Get these from: https://grafana.com/docs/grafana-cloud/send-data/logs/logs-loki/ + LOKI_URL: ${LOKI_URL} + LOKI_USERNAME: ${LOKI_USERNAME} + LOKI_PASSWORD: ${LOKI_PASSWORD} + + # Alloy - Grafana Cloud Prometheus Configuration + # Get these from: https://grafana.com/docs/grafana-cloud/send-data/metrics/metrics-prometheus/ + PROMETHEUS_REMOTE_WRITE_URL: ${PROMETHEUS_REMOTE_WRITE_URL} + PROMETHEUS_USERNAME: ${PROMETHEUS_USERNAME} + PROMETHEUS_PASSWORD: ${PROMETHEUS_PASSWORD} + + # Database - MongoDB + DATABASE: ${DATABASE:-mongodb://mongo:27017/restful-mongoose} + + # Feature Flags (Optional - leave empty to disable) + FEATURE_FLAG_PROVIDER: ${FEATURE_FLAG_PROVIDER:-} + POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + GROWTHBOOK_API_KEY: ${GROWTHBOOK_API_KEY:-} + GROWTHBOOK_API_HOST: ${GROWTHBOOK_API_HOST:-} + depends_on: + - mongo + restart: unless-stopped + + # ============================================================================ + # MongoDB Database + # ============================================================================ + mongo: + image: mongo:7.0 + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USERNAME:-admin} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-password} + MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-restful-mongoose} + volumes: + - mongo_data:/data/db + restart: unless-stopped + +volumes: + mongo_data: diff --git a/examples/with-restful-mongoose/packages/feature-flags/package.json b/examples/with-restful-mongoose/packages/feature-flags/package.json index 98fb8913..90487767 100644 --- a/examples/with-restful-mongoose/packages/feature-flags/package.json +++ b/examples/with-restful-mongoose/packages/feature-flags/package.json @@ -2,20 +2,32 @@ "name": "@repo/feature-flags", "version": "0.0.0", "files": [ - "src/**" + "src/**", + "dist/**" ], "exports": { - "./shared": "./src/shared/index.ts", - "./api": "./src/api/index.ts", - "./web": "./src/web/index.ts" + "./shared": { + "types": "./src/shared/index.ts", + "default": "./dist/shared/index.js" + }, + "./api": { + "types": "./src/api/index.ts", + "default": "./dist/api/index.js" + }, + "./web": { + "types": "./src/web/index.ts", + "import": "./dist/web/index.mjs", + "require": "./dist/web/index.js" + } }, "license": "MIT", "scripts": { - "build": "" + "build": "tsup" }, "devDependencies": { "@repo/typescript-config": "*", "@types/node": "24.3.0", + "@types/react": "^18.3.12", "tsup": "8.5.0", "typescript": "5.9.2" }, @@ -28,4 +40,4 @@ "react": "19.1.1", "next": "15.5.0" } -} +} \ No newline at end of file diff --git a/examples/with-restful-mongoose/packages/feature-flags/tsup.config.ts b/examples/with-restful-mongoose/packages/feature-flags/tsup.config.ts index e51d47b1..f3900783 100644 --- a/examples/with-restful-mongoose/packages/feature-flags/tsup.config.ts +++ b/examples/with-restful-mongoose/packages/feature-flags/tsup.config.ts @@ -13,6 +13,7 @@ export default defineConfig([ splitting: false, clean: true, sourcemap: true, + dts: true, target: 'node24', shims: true, bundle: true, diff --git a/examples/with-restful-mongoose/packages/utils/src/safe-fetch.ts b/examples/with-restful-mongoose/packages/utils/src/safe-fetch.ts index 214cebea..9472f270 100644 --- a/examples/with-restful-mongoose/packages/utils/src/safe-fetch.ts +++ b/examples/with-restful-mongoose/packages/utils/src/safe-fetch.ts @@ -5,8 +5,8 @@ import { safe } from './safe-functions'; * @description Wraps the `fetch` function with error handling using the `safe` utility. * Returns a `Safe` type object containing the JSON response or an error message. * - * @param {RequestInfo | URL} input - The input to fetch, similar to the `fetch` API. - * @param {RequestInit} [init] - Optional settings to apply to the request. + * @param {string | URL} input - The input to fetch, similar to the `fetch` API. + * @param {any} [init] - Optional settings to apply to the request. * * @returns {Promise>} - A `Safe` object containing the JSON-parsed data if successful, * or an error message if an error occurs. @@ -23,7 +23,7 @@ import { safe } from './safe-functions'; * }; * ``` */ -export async function safeFetch(input: RequestInfo | URL, init?: RequestInit) { +export async function safeFetch(input: string | URL, init?: any) { const response = await safe(fetch(input, init)); if (response.success) { const jsonResponse = await safe(response.data.json()); From a5516094620a0c3fd4bf02edfc51f50278980405 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 23 Oct 2025 13:03:39 -0400 Subject: [PATCH 4/5] feat: dockerfile restful prisma --- .../with-restful-prisma/apps/api/Dockerfile | 41 +++++- .../with-restful-prisma/apps/api/alloy.config | 136 ++++++++++++++++++ .../with-restful-prisma/apps/api/package.json | 3 +- .../with-restful-prisma/apps/api/src/app.ts | 9 +- .../apps/api/src/plugins/metrics.ts | 84 +++++++++++ .../with-restful-prisma/apps/api/start.sh | 39 +++++ .../docker-compose.observability.yml | 61 ++++++++ .../packages/feature-flags/package.json | 24 +++- .../packages/feature-flags/tsup.config.ts | 1 + .../packages/utils/src/safe-fetch.ts | 6 +- 10 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 examples/with-restful-prisma/apps/api/alloy.config create mode 100644 examples/with-restful-prisma/apps/api/src/plugins/metrics.ts create mode 100644 examples/with-restful-prisma/apps/api/start.sh create mode 100644 examples/with-restful-prisma/docker-compose.observability.yml diff --git a/examples/with-restful-prisma/apps/api/Dockerfile b/examples/with-restful-prisma/apps/api/Dockerfile index dc8ee3a2..8090190c 100644 --- a/examples/with-restful-prisma/apps/api/Dockerfile +++ b/examples/with-restful-prisma/apps/api/Dockerfile @@ -1,8 +1,7 @@ FROM node:22-alpine AS base ENV SCOPE=@repo/api -ARG APP_PATH -ENV APP_PATH=$APP_PATH +ENV APP_PATH=apps/api ### Builder FROM base AS builder @@ -52,17 +51,34 @@ FROM base AS runner # Environment and dependencies ENV NODE_ENV production -RUN apk add --no-cache tzdata +RUN apk add --no-cache tzdata curl bash unzip libc6-compat libstdc++ RUN apk update ENV TZ=America/Caracas WORKDIR /app +# Install Grafana Alloy (detect architecture) +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + ALLOY_ARCH="amd64"; \ + elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \ + ALLOY_ARCH="arm64"; \ + else \ + echo "Unsupported architecture: $ARCH" && exit 1; \ + fi && \ + curl -L -o /tmp/alloy.zip "https://github.com/grafana/alloy/releases/latest/download/alloy-linux-${ALLOY_ARCH}.zip" && \ + unzip /tmp/alloy.zip -d /tmp && \ + mv /tmp/alloy-linux-${ALLOY_ARCH} /usr/local/bin/alloy && \ + chmod +x /usr/local/bin/alloy && \ + rm /tmp/alloy.zip + # User RUN addgroup --system --gid 1001 avilatek RUN adduser --system --uid 1001 avilatek -USER avilatek -# Copy dist + +# Copy dist and dependencies COPY --from=installer /app/package*.json . +COPY --from=installer /app/node_modules ./node_modules +COPY --from=installer /app/packages ./packages COPY --from=installer /app/${APP_PATH}/dist ./${APP_PATH}/dist COPY --from=installer /app/${APP_PATH}/src/*.env* ./${APP_PATH}/src/*.env* @@ -70,10 +86,23 @@ COPY --from=installer /app/${APP_PATH}/package.json ./${APP_PATH}/package.json # Prisma COPY --from=installer /app/${APP_PATH}/prisma ./${APP_PATH}/prisma +# Copy Alloy configuration +COPY --from=builder /app/${APP_PATH}/alloy.config ./${APP_PATH}/alloy.config + +# Copy startup script +COPY --from=builder /app/${APP_PATH}/start.sh /app/start.sh +RUN chmod +x /app/start.sh + +# Give permissions to alloy storage path and startup script +RUN mkdir -p /tmp/alloy && \ + chown -R avilatek:avilatek /tmp/alloy && \ + chown avilatek:avilatek /app/start.sh + +USER avilatek ARG PORT ENV PORT=$PORT EXPOSE $PORT WORKDIR /app/${APP_PATH} -CMD ["npm", "start"] +CMD ["/bin/bash", "/app/start.sh"] diff --git a/examples/with-restful-prisma/apps/api/alloy.config b/examples/with-restful-prisma/apps/api/alloy.config new file mode 100644 index 00000000..eed5679b --- /dev/null +++ b/examples/with-restful-prisma/apps/api/alloy.config @@ -0,0 +1,136 @@ +// Grafana Alloy Configuration for API Application +// This configuration collects logs and metrics from the Node.js API + +// ============================================================================ +// LOGGING PIPELINE +// ============================================================================ + +// Collect logs from the application's log file +loki.source.file "app_logs" { + targets = [ + { + __path__ = "/tmp/api.log", + job = env("APP_NAME"), + app = env("APP_NAME"), + env = env("ENVIRONMENT"), + instance = env("HOSTNAME"), + }, + ] + + forward_to = [loki.process.add_labels.receiver] +} + +// Process and enrich logs +loki.process "add_labels" { + // Add additional labels + stage.static_labels { + values = { + cluster = env("CLUSTER_NAME"), + service = env("APP_NAME"), + } + } + + // Parse JSON logs if they exist + stage.json { + expressions = { + level = "level", + message = "msg", + timestamp = "time", + } + } + + // Extract log level + stage.labels { + values = { + level = "level", + } + } + + forward_to = [loki.write.default.receiver] +} + +// Send logs to Loki +loki.write "default" { + endpoint { + url = env("LOKI_URL") + + // Optional: Add basic auth if needed + basic_auth { + username = env("LOKI_USERNAME") + password = env("LOKI_PASSWORD") + } + } + + external_labels = { + cluster = env("CLUSTER_NAME"), + } +} + +// ============================================================================ +// METRICS PIPELINE +// ============================================================================ + +// Scrape metrics from the application (if it exposes /metrics) +prometheus.scrape "app_metrics" { + targets = [ + { + __address__ = "localhost:" + env("APP_PORT"), + job = env("APP_NAME"), + app = env("APP_NAME"), + env = env("ENVIRONMENT"), + }, + ] + + forward_to = [prometheus.relabel.add_labels.receiver] + scrape_interval = "15s" + metrics_path = "/metrics" +} + +// Add labels to metrics +prometheus.relabel "add_labels" { + rule { + target_label = "cluster" + replacement = env("CLUSTER_NAME") + } + + rule { + target_label = "instance" + replacement = env("HOSTNAME") + } + + forward_to = [prometheus.remote_write.default.receiver] +} + +// Send metrics to Prometheus/Mimir +prometheus.remote_write "default" { + endpoint { + url = env("PROMETHEUS_REMOTE_WRITE_URL") + + // Optional: Add basic auth if needed + basic_auth { + username = env("PROMETHEUS_USERNAME") + password = env("PROMETHEUS_PASSWORD") + } + } + + external_labels = { + cluster = env("CLUSTER_NAME"), + env = env("ENVIRONMENT"), + } +} + +// ============================================================================ +// PROCESS METRICS (CPU, Memory, etc.) +// ============================================================================ + +// Collect host/container metrics +prometheus.exporter.unix "node" { + include_exporter_metrics = true +} + +prometheus.scrape "node_metrics" { + targets = prometheus.exporter.unix.node.targets + forward_to = [prometheus.remote_write.default.receiver] + job_name = "node-exporter" +} + diff --git a/examples/with-restful-prisma/apps/api/package.json b/examples/with-restful-prisma/apps/api/package.json index 413a8892..37b52f1c 100644 --- a/examples/with-restful-prisma/apps/api/package.json +++ b/examples/with-restful-prisma/apps/api/package.json @@ -49,8 +49,9 @@ "pino-pretty": "13.0.0", "pino-loki": "2.6.0", "postmark": "4.0.5", + "prom-client": "^15.1.3", "@repo/feature-flags": "*", "slug": "11.0.0", "zod": "4.0.5" } -} +} \ No newline at end of file diff --git a/examples/with-restful-prisma/apps/api/src/app.ts b/examples/with-restful-prisma/apps/api/src/app.ts index 3cf4931a..574131c1 100644 --- a/examples/with-restful-prisma/apps/api/src/app.ts +++ b/examples/with-restful-prisma/apps/api/src/app.ts @@ -8,6 +8,7 @@ import rateLimit from '@fastify/rate-limit'; import * as Sentry from '@sentry/node'; import Fastify, { FastifyHttpOptions } from 'fastify'; import featureFlagsPlugin from './plugins/feature-flags'; +import metricsPlugin from './plugins/metrics'; import { TFeatureFlagProvider, featureFlagProviders, @@ -48,7 +49,10 @@ export async function createApp() { credentials: true, }); - await app.register(featureFlagsPlugin, { + await app.register(metricsPlugin); + + if (process.env.FEATURE_FLAG_PROVIDER) { + await app.register(featureFlagsPlugin, { provider, postHog: provider === featureFlagProviders.post_hog @@ -64,7 +68,8 @@ export async function createApp() { apiHost: process.env.GROWTHBOOK_API_HOST, } : undefined, - }); + }); + } await app.ready(); diff --git a/examples/with-restful-prisma/apps/api/src/plugins/metrics.ts b/examples/with-restful-prisma/apps/api/src/plugins/metrics.ts new file mode 100644 index 00000000..e8751f9c --- /dev/null +++ b/examples/with-restful-prisma/apps/api/src/plugins/metrics.ts @@ -0,0 +1,84 @@ +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import { register, Counter, Histogram, collectDefaultMetrics } from 'prom-client'; + +// Create metrics +const httpRequestCounter = new Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], +}); + +const httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.1, 0.5, 1, 2, 5, 10], +}); + +const restApiRequestCounter = new Counter({ + name: 'rest_api_requests_total', + help: 'Total number of REST API requests', + labelNames: ['operation_name', 'operation_type'], +}); + +// Collect default metrics (CPU, memory, etc.) +collectDefaultMetrics({ register }); + +const metricsPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { + // Hook to track all HTTP requests + fastify.addHook('onRequest', async (request, reply) => { + // Store start time for duration calculation + request.requestStartTime = Date.now(); + }); + + fastify.addHook('onResponse', async (request, reply) => { + const duration = (Date.now() - (request.requestStartTime || Date.now())) / 1000; + + const route = request.routeOptions?.url || request.url; + const method = request.method; + const statusCode = reply.statusCode.toString(); + + // Increment request counter + httpRequestCounter.inc({ + method, + route, + status_code: statusCode, + }); + + // Record request duration + httpRequestDuration.observe( + { + method, + route, + status_code: statusCode, + }, + duration + ); + }); + + // Endpoint to expose metrics + fastify.get('/metrics', async (request, reply) => { + reply.header('Content-Type', register.contentType); + return register.metrics(); + }); + + // Health check endpoint + fastify.get('/health', async (request, reply) => { + return { status: 'ok', timestamp: new Date().toISOString() }; + }); +}; + +// Extend FastifyRequest type to include requestStartTime +declare module 'fastify' { + interface FastifyRequest { + requestStartTime?: number; + } +} + +export default fp(metricsPlugin, { + name: 'metrics', +}); + +export { httpRequestCounter, httpRequestDuration, restApiRequestCounter, register }; + diff --git a/examples/with-restful-prisma/apps/api/start.sh b/examples/with-restful-prisma/apps/api/start.sh new file mode 100644 index 00000000..a870d779 --- /dev/null +++ b/examples/with-restful-prisma/apps/api/start.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# ============================================================================== +# API Container Startup Script +# ============================================================================== +# This script is executed automatically inside the Docker container. +# DO NOT run this script manually outside of Docker. +# +# This script: +# 1. Starts Grafana Alloy agent for observability +# 2. Starts the Node.js API application +# 3. Redirects logs to /tmp/api.log for Alloy to read and send to Loki +# ============================================================================== + +set -e + +# Create log file +touch /tmp/api.log + +echo "=================================" +echo "Starting Grafana Alloy..." +echo "=================================" +/usr/local/bin/alloy run /app/apps/api/alloy.config \ + --server.http.listen-addr=0.0.0.0:12345 \ + --storage.path=/tmp/alloy & + +ALLOY_PID=$! +echo "Alloy started with PID: $ALLOY_PID" +echo "Alloy will read logs from /tmp/api.log and send to Grafana Cloud Loki" + +echo "=================================" +echo "Starting API application..." +echo "=================================" +echo "Logs are being written to /tmp/api.log" +echo "Alloy is sending logs to Grafana Cloud Loki" + +cd /app/apps/api +# Start app and redirect logs to file (Alloy reads from here) and also show in stdout +npm start 2>&1 | tee -a /tmp/api.log + diff --git a/examples/with-restful-prisma/docker-compose.observability.yml b/examples/with-restful-prisma/docker-compose.observability.yml new file mode 100644 index 00000000..a2ffdb4d --- /dev/null +++ b/examples/with-restful-prisma/docker-compose.observability.yml @@ -0,0 +1,61 @@ +services: + # ============================================================================ + # API Application with Alloy + # ============================================================================ + api: + # platform: linux/amd64 # Use linux/arm64 for Apple Silicon + build: + context: . + dockerfile: ./apps/api/Dockerfile + args: + APP_PATH: apps/api + PORT: 4000 + ports: + - "4000:4000" + environment: + # Application + PORT: 4000 + NODE_ENV: production + LOG_LEVEL: ${LOG_LEVEL:-info} + CORS_ORIGINS: ${CORS_ORIGINS:-["*"]} + HOST: 0.0.0.0 + JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-here} + POSTMARK_API_KEY: ${POSTMARK_API_KEY:-your-postmark-key} + SENTRY_DSN: ${SENTRY_DSN:-} + POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} + + # Alloy - Application Info + APP_NAME: restful-prisma-api + ENVIRONMENT: ${ENVIRONMENT:-development} + HOSTNAME: ${HOSTNAME:-api-container} + CLUSTER_NAME: ${CLUSTER_NAME:-local-dev} + APP_PORT: 4000 + + # Alloy - Grafana Cloud Loki Configuration + # Get these from: https://grafana.com/docs/grafana-cloud/send-data/logs/logs-loki/ + LOKI_URL: ${LOKI_URL} + LOKI_USERNAME: ${LOKI_USERNAME} + LOKI_PASSWORD: ${LOKI_PASSWORD} + + # Alloy - Grafana Cloud Prometheus Configuration + # Get these from: https://grafana.com/docs/grafana-cloud/send-data/metrics/metrics-prometheus/ + PROMETHEUS_REMOTE_WRITE_URL: ${PROMETHEUS_REMOTE_WRITE_URL} + PROMETHEUS_USERNAME: ${PROMETHEUS_USERNAME} + PROMETHEUS_PASSWORD: ${PROMETHEUS_PASSWORD} + + # Database - SQLite (archivo local) + DATABASE_URL: ${DATABASE_URL:-file:/app/apps/api/prisma/dev.db} + + # Feature Flags (Optional - leave empty to disable) + FEATURE_FLAG_PROVIDER: ${FEATURE_FLAG_PROVIDER:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + GROWTHBOOK_API_KEY: ${GROWTHBOOK_API_KEY:-} + GROWTHBOOK_API_HOST: ${GROWTHBOOK_API_HOST:-} + volumes: + # Persistir la base de datos SQLite + - sqlite_data:/app/apps/api/prisma + restart: unless-stopped + +volumes: + sqlite_data: + diff --git a/examples/with-restful-prisma/packages/feature-flags/package.json b/examples/with-restful-prisma/packages/feature-flags/package.json index 5672e037..f8624f4a 100644 --- a/examples/with-restful-prisma/packages/feature-flags/package.json +++ b/examples/with-restful-prisma/packages/feature-flags/package.json @@ -2,20 +2,32 @@ "name": "@repo/feature-flags", "version": "0.0.0", "files": [ - "src/**" + "src/**", + "dist/**" ], "exports": { - "./shared": "./src/shared/index.ts", - "./api": "./src/api/index.ts", - "./web": "./src/web/index.ts" + "./shared": { + "types": "./src/shared/index.ts", + "default": "./dist/shared/index.js" + }, + "./api": { + "types": "./src/api/index.ts", + "default": "./dist/api/index.js" + }, + "./web": { + "types": "./src/web/index.ts", + "import": "./dist/web/index.mjs", + "require": "./dist/web/index.js" + } }, "license": "MIT", "scripts": { - "build": "" + "build": "tsup" }, "devDependencies": { "@repo/typescript-config": "*", "@types/node": "24.1.0", + "@types/react": "^18.3.12", "tsup": "8.5.0", "typescript": "5.8.3" }, @@ -28,4 +40,4 @@ "react": "19.1.1", "next": "15.4.4" } -} +} \ No newline at end of file diff --git a/examples/with-restful-prisma/packages/feature-flags/tsup.config.ts b/examples/with-restful-prisma/packages/feature-flags/tsup.config.ts index d857ce89..306b5c56 100644 --- a/examples/with-restful-prisma/packages/feature-flags/tsup.config.ts +++ b/examples/with-restful-prisma/packages/feature-flags/tsup.config.ts @@ -13,6 +13,7 @@ export default defineConfig([ splitting: false, clean: true, sourcemap: true, + dts: true, target: 'node20', shims: true, bundle: true, diff --git a/examples/with-restful-prisma/packages/utils/src/safe-fetch.ts b/examples/with-restful-prisma/packages/utils/src/safe-fetch.ts index 214cebea..9472f270 100644 --- a/examples/with-restful-prisma/packages/utils/src/safe-fetch.ts +++ b/examples/with-restful-prisma/packages/utils/src/safe-fetch.ts @@ -5,8 +5,8 @@ import { safe } from './safe-functions'; * @description Wraps the `fetch` function with error handling using the `safe` utility. * Returns a `Safe` type object containing the JSON response or an error message. * - * @param {RequestInfo | URL} input - The input to fetch, similar to the `fetch` API. - * @param {RequestInit} [init] - Optional settings to apply to the request. + * @param {string | URL} input - The input to fetch, similar to the `fetch` API. + * @param {any} [init] - Optional settings to apply to the request. * * @returns {Promise>} - A `Safe` object containing the JSON-parsed data if successful, * or an error message if an error occurs. @@ -23,7 +23,7 @@ import { safe } from './safe-functions'; * }; * ``` */ -export async function safeFetch(input: RequestInfo | URL, init?: RequestInit) { +export async function safeFetch(input: string | URL, init?: any) { const response = await safe(fetch(input, init)); if (response.success) { const jsonResponse = await safe(response.data.json()); From 9c211e3d76989267900bdcf798b7f64f62bd3ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20S=C3=A1nchez=20P=2E?= <42817881+lesanpi@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:27:09 +0000 Subject: [PATCH 5/5] feat: add dockerfile for restful prisma --- examples/with-restful-prisma/apps/api/src/app.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/examples/with-restful-prisma/apps/api/src/app.ts b/examples/with-restful-prisma/apps/api/src/app.ts index 574131c1..c8129dfa 100644 --- a/examples/with-restful-prisma/apps/api/src/app.ts +++ b/examples/with-restful-prisma/apps/api/src/app.ts @@ -23,13 +23,6 @@ export async function createApp() { config = { logger: { level: 'info', - transport: { - target: '@axiomhq/pino', - options: { - dataset: process.env.AXIOM_DATASET, - token: process.env.AXIOM_TOKEN, - }, - }, }, }; }