DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Observabilidad de frontend con CloudWatch RUM y X Ray para Next js

Los logs de CloudWatch te dicen qué pasó en tu servidor. Pero cuando un usuario reporta "la página está lenta", los logs del servidor muestran que todo respondió en 200ms. El problema está en el cliente: red lenta, CPU saturada, rendering bloqueado. Observabilidad de frontend es lo que llena ese vacío. En este artículo cubro la integración completa de CloudWatch RUM con Next.js, X-Ray para correlacionar traces, alertas accionables y dashboards que realmente usas.

El gap entre cliente y servidor

flowchart LR
    subgraph Client[Métricas cliente]
        FCP[First Contentful Paint]
        LCP[Largest Contentful Paint]
        INP[Interaction to Next Paint]
        CLS[Cumulative Layout Shift]
        JSErrors[JS errors]
        APICalls[API call duration]
    end

    subgraph Server[Métricas servidor]
        Latency[Lambda duration]
        Errors[Lambda errors]
        DBQuery[DB query time]
    end

    subgraph Correlation[Punto de correlación]
        TraceId[X-Ray Trace ID]
    end

    Client --> TraceId
    Server --> TraceId
    TraceId --> Full[Experiencia completa usuario]

    style TraceId fill:#f39c12,color:#fff
    style Full fill:#2ecc71,color:#fff
Enter fullscreen mode Exit fullscreen mode

CloudWatch RUM provee las métricas cliente. X-Ray las del servidor. El Trace ID une ambas.

Setup de CloudWatch RUM

// infra/rum-stack.ts
import * as rum from 'aws-cdk-lib/aws-rum';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as iam from 'aws-cdk-lib/aws-iam';

export class RumStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    // Identity Pool para RUM (no requiere login)
    const identityPool = new cognito.CfnIdentityPool(this, 'RumIdentityPool', {
      allowUnauthenticatedIdentities: true,
      cognitoIdentityProviders: [],
    });

    const unauthenticatedRole = new iam.Role(this, 'RumGuestRole', {
      assumedBy: new iam.FederatedPrincipal(
        'cognito-identity.amazonaws.com',
        {
          StringEquals: { 'cognito-identity.amazonaws.com:aud': identityPool.ref },
          'ForAnyValue:StringLike': { 'cognito-identity.amazonaws.com:amr': 'unauthenticated' },
        },
        'sts:AssumeRoleWithWebIdentity'
      ),
    });

    unauthenticatedRole.addToPolicy(
      new iam.PolicyStatement({
        actions: ['rum:PutRumEvents'],
        resources: [`arn:aws:rum:${this.region}:${this.account}:appmonitor/mi-app-monitor`],
      })
    );

    new cognito.CfnIdentityPoolRoleAttachment(this, 'RumRoleAttachment', {
      identityPoolId: identityPool.ref,
      roles: { unauthenticated: unauthenticatedRole.roleArn },
    });

    const rumMonitor = new rum.CfnAppMonitor(this, 'AppMonitor', {
      name: 'mi-app-monitor',
      domain: 'app.miempresa.com',
      cwLogEnabled: true,
      appMonitorConfiguration: {
        allowCookies: true,
        enableXRay: true,
        sessionSampleRate: 1.0, // 100% en el setup inicial, ajustar después
        telemetries: ['errors', 'performance', 'http'],
        identityPoolId: identityPool.ref,
        guestRoleArn: unauthenticatedRole.roleArn,
        metricDestinations: [
          {
            destination: 'CloudWatch',
            iamRoleArn: metricsRole.roleArn,
            metricDefinitions: [
              {
                name: 'WebVitals_LCP',
                namespace: 'FrontendApp',
                eventPattern: JSON.stringify({
                  metadata: {
                    'aws:plugin': ['web-vitals'],
                  },
                  event_details: {
                    name: ['largest_contentful_paint'],
                  },
                }),
                unitLabel: 'Milliseconds',
                valueKey: 'event_details.value',
                dimensionKeys: {
                  'metadata.pageId': 'Page',
                  'metadata.deviceType': 'Device',
                },
              },
            ],
          },
        ],
      },
    });

    new cdk.CfnOutput(this, 'MonitorName', { value: rumMonitor.name! });
    new cdk.CfnOutput(this, 'IdentityPoolId', { value: identityPool.ref });
  }
}
Enter fullscreen mode Exit fullscreen mode

Integración en Next.js

El script de RUM debe cargar lo antes posible. Lo meto en el layout root:

// src/app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es">
      <head>
        <Script id="rum-script" strategy="beforeInteractive">
          {`
            (function(n,i,v,r,s,c,x,z){
              x=window.AwsRumClient={q:[],n:n,i:i,v:v,r:r,c:c};
              window[n]=function(c,p){x.q.push({c:c,p:p});};
              z=document.createElement('script');
              z.async=true;z.src=s;
              document.head.insertBefore(z,document.head.firstChild);
            })(
              'cwr',
              '${process.env.NEXT_PUBLIC_RUM_APP_MONITOR_ID}',
              '1.0.0',
              'us-east-1',
              'https://client.rum.us-east-1.amazonaws.com/1.19.0/cwr.js',
              {
                sessionSampleRate: 1,
                identityPoolId: '${process.env.NEXT_PUBLIC_RUM_IDENTITY_POOL_ID}',
                endpoint: 'https://dataplane.rum.us-east-1.amazonaws.com',
                telemetries: ['performance', 'errors', 'http'],
                allowCookies: true,
                enableXRay: true
              }
            );
          `}
        </Script>
      </head>
      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wrapper para tracking custom

RUM tiene un agente built-in pero para tracking de negocio necesitamos eventos custom:

// src/lib/rum/rum-client.ts
declare global {
  interface Window {
    cwr: (command: string, ...args: any[]) => void;
  }
}

type EventName =
  | 'product_viewed'
  | 'product_added_to_cart'
  | 'checkout_started'
  | 'purchase_completed'
  | 'search_performed'
  | 'video_played'
  | 'form_error';

export function trackEvent(event: EventName, data: Record<string, any> = {}) {
  if (typeof window === 'undefined' || !window.cwr) return;

  try {
    window.cwr('recordEvent', {
      type: event,
      data,
    });
  } catch (err) {
    console.error('RUM track error:', err);
  }
}

export function trackPageView(pageId: string, metadata: Record<string, any> = {}) {
  if (typeof window === 'undefined' || !window.cwr) return;
  window.cwr('recordPageView', { pageId, pageTags: metadata });
}

export function trackError(error: Error, context: Record<string, any> = {}) {
  if (typeof window === 'undefined' || !window.cwr) return;

  window.cwr('recordError', error);
  window.cwr('recordEvent', {
    type: 'custom_error',
    data: {
      message: error.message,
      stack: error.stack,
      ...context,
    },
  });
}

export function setUser(userId: string, attributes: Record<string, any> = {}) {
  if (typeof window === 'undefined' || !window.cwr) return;

  window.cwr('addSessionAttributes', {
    userId,
    ...attributes,
  });
}
Enter fullscreen mode Exit fullscreen mode

Usando en componentes

// src/components/product-card.tsx
'use client';

import { trackEvent } from '@/lib/rum/rum-client';
import { useEffect } from 'react';

export function ProductCard({ product }: { product: Product }) {
  useEffect(() => {
    trackEvent('product_viewed', {
      productId: product.id,
      productName: product.name,
      price: product.price,
      category: product.category,
    });
  }, [product.id]);

  const handleAddToCart = async () => {
    trackEvent('product_added_to_cart', {
      productId: product.id,
      price: product.price,
    });
    // ... lógica real
  };

  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={handleAddToCart}>Agregar</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Correlación con X-Ray

Para que los requests del frontend se correlacionen con las traces del backend, hay que propagar el trace ID:

// src/lib/fetch-with-trace.ts
declare global {
  interface Window {
    cwr: any;
  }
}

export async function fetchWithTrace(
  url: string,
  options: RequestInit = {}
): Promise<Response> {
  const headers = new Headers(options.headers);

  // Obtener el trace ID actual de RUM
  if (typeof window !== 'undefined' && window.cwr) {
    try {
      const traceId = window.cwr('getCurrentTraceId');
      if (traceId) {
        headers.set('X-Amzn-Trace-Id', `Root=${traceId}`);
      }
    } catch {
      // Ignorar si no hay trace
    }
  }

  return fetch(url, { ...options, headers });
}
Enter fullscreen mode Exit fullscreen mode

Del lado del servidor, el X-Ray SDK reconoce ese header automáticamente:

// lambda/handler.ts
import AWSXRay from 'aws-xray-sdk-core';
import { captureAWSv3Client } from 'aws-xray-sdk-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

const dynamo = captureAWSv3Client(new DynamoDBClient({}));

export const handler = async (event: any) => {
  const segment = AWSXRay.getSegment();

  const subsegment = segment.addNewSubsegment('business-logic');
  try {
    const result = await doWork(event);
    subsegment.addMetadata('result', { itemCount: result.length });
    return result;
  } catch (error) {
    subsegment.addError(error);
    throw error;
  } finally {
    subsegment.close();
  }
};
Enter fullscreen mode Exit fullscreen mode

Dashboards efectivos

Los dashboards malos son los que nadie mira. Los buenos cuentan una historia. Mi template:

// infra/dashboard.ts
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';

const dashboard = new cloudwatch.Dashboard(this, 'FrontendDashboard', {
  dashboardName: 'frontend-app-health',
});

dashboard.addWidgets(
  // Row 1: Health overview
  new cloudwatch.TextWidget({
    markdown: '# Frontend Health\n## Real user metrics',
    width: 24,
    height: 2,
  }),

  // Row 2: Core Web Vitals
  new cloudwatch.GraphWidget({
    title: 'LCP (P75)',
    left: [
      new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'WebVitals_LCP',
        statistic: 'p75',
        period: cdk.Duration.minutes(5),
      }),
    ],
    leftYAxis: { min: 0, max: 5000 },
    leftAnnotations: [
      { value: 2500, label: 'Objetivo', color: cloudwatch.Color.GREEN },
      { value: 4000, label: 'Límite', color: cloudwatch.Color.RED },
    ],
    width: 8,
  }),

  new cloudwatch.GraphWidget({
    title: 'CLS (P75)',
    left: [
      new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'WebVitals_CLS',
        statistic: 'p75',
      }),
    ],
    leftAnnotations: [{ value: 0.1, color: cloudwatch.Color.GREEN }],
    width: 8,
  }),

  new cloudwatch.GraphWidget({
    title: 'INP (P75)',
    left: [
      new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'WebVitals_INP',
        statistic: 'p75',
      }),
    ],
    leftAnnotations: [{ value: 200, color: cloudwatch.Color.GREEN }],
    width: 8,
  }),

  // Row 3: Errores y conversión
  new cloudwatch.GraphWidget({
    title: 'JS Errors por minuto',
    left: [
      new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'JsError',
        statistic: 'Sum',
      }),
    ],
    width: 12,
  }),

  new cloudwatch.SingleValueWidget({
    title: 'Conversión últimas 24h',
    metrics: [
      new cloudwatch.MathExpression({
        expression: 'm2 / m1 * 100',
        usingMetrics: {
          m1: new cloudwatch.Metric({
            namespace: 'FrontendApp',
            metricName: 'checkout_started',
            statistic: 'Sum',
          }),
          m2: new cloudwatch.Metric({
            namespace: 'FrontendApp',
            metricName: 'purchase_completed',
            statistic: 'Sum',
          }),
        },
        period: cdk.Duration.hours(24),
        label: 'Conversion %',
      }),
    ],
    width: 12,
  }),

  // Row 4: Distribución por dispositivo
  new cloudwatch.GraphWidget({
    title: 'LCP por tipo de dispositivo',
    left: [
      new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'WebVitals_LCP',
        statistic: 'p75',
        dimensionsMap: { Device: 'mobile' },
        label: 'Mobile',
      }),
      new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'WebVitals_LCP',
        statistic: 'p75',
        dimensionsMap: { Device: 'desktop' },
        label: 'Desktop',
      }),
      new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'WebVitals_LCP',
        statistic: 'p75',
        dimensionsMap: { Device: 'tablet' },
        label: 'Tablet',
      }),
    ],
    width: 24,
  }),
);
Enter fullscreen mode Exit fullscreen mode

Alarmas accionables

La diferencia entre una alarma útil y spam:

// Alarma 1: LCP degrado sostenido
new cloudwatch.Alarm(this, 'LcpDegraded', {
  metric: new cloudwatch.Metric({
    namespace: 'FrontendApp',
    metricName: 'WebVitals_LCP',
    statistic: 'p75',
  }),
  threshold: 3000,
  evaluationPeriods: 3,
  datapointsToAlarm: 3,
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
  alarmDescription: 'LCP P75 arriba de 3s por 3 periodos consecutivos',
});

// Alarma 2: Spike de errores JS
new cloudwatch.Alarm(this, 'JsErrorSpike', {
  metric: new cloudwatch.MathExpression({
    expression: 'ABS((m1 - AVG(m1, -60)) / AVG(m1, -60))',
    usingMetrics: {
      m1: new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'JsError',
        statistic: 'Sum',
      }),
    },
  }),
  threshold: 2, // 200% más que el promedio de la última hora
  evaluationPeriods: 2,
  alarmDescription: 'JS errors 2x arriba del baseline',
});

// Alarma 3: Conversión cayó
new cloudwatch.Alarm(this, 'ConversionDrop', {
  metric: new cloudwatch.MathExpression({
    expression: '(purchases / checkouts) * 100',
    usingMetrics: {
      purchases: new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'purchase_completed',
        statistic: 'Sum',
      }),
      checkouts: new cloudwatch.Metric({
        namespace: 'FrontendApp',
        metricName: 'checkout_started',
        statistic: 'Sum',
      }),
    },
    period: cdk.Duration.hours(1),
  }),
  threshold: 1.5, // Si cae bajo 1.5% de conversión
  comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
  evaluationPeriods: 2,
});
Enter fullscreen mode Exit fullscreen mode

Logs Insights para debugging

Cuando una alarma dispara, el runbook típico:

-- Query 1: Ver errores JS agrupados por mensaje
fields @timestamp, event_details.error.message, event_details.error.stack, metadata.pageId, metadata.userAgent
| filter event_type = "com.amazon.rum.js_error_event"
| stats count(*) as occurrences by event_details.error.message
| sort occurrences desc
| limit 20
Enter fullscreen mode Exit fullscreen mode
-- Query 2: Sesiones lentas específicas
fields @timestamp, metadata.pageId, event_details.value, metadata.userAgent
| filter event_type = "com.amazon.rum.performance_navigation_event"
  and event_details.value > 5000
| sort @timestamp desc
| limit 100
Enter fullscreen mode Exit fullscreen mode
-- Query 3: Distribución de LCP por país
fields event_details.value, metadata.countryCode
| filter event_type = "com.amazon.rum.largest_contentful_paint_event"
| stats p75(event_details.value) as lcp_p75 by metadata.countryCode
| sort lcp_p75 desc
Enter fullscreen mode Exit fullscreen mode

Anomaly detection

CloudWatch Anomaly Detection aprende patrones normales. Útil para métricas que varían por hora del día:

new cloudwatch.Alarm(this, 'TrafficAnomaly', {
  metric: new cloudwatch.AnomalyDetectionQuery({
    metric: new cloudwatch.Metric({
      namespace: 'FrontendApp',
      metricName: 'PageViews',
      statistic: 'Sum',
    }),
    deviations: 2,
  }),
  threshold: 0,
  comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_LOWER_OR_GREATER_THAN_UPPER_THRESHOLD,
  evaluationPeriods: 3,
});
Enter fullscreen mode Exit fullscreen mode

Lo que aprendí monitoreando en producción

1. 1% de sampling es suficiente a escala.

Con 1M de sesiones diarias, 10k muestras te dan P99 confiable. Bajar el sample rate reduce costos drásticamente.

2. Los dashboards se pudren.

Cada quarter reviso qué widgets nadie mira y los quito. Un dashboard de 40 widgets es inservible.

3. Las alarmas con threshold fijo son malas.

A las 3am tu LCP puede ser distinto al de las 11am. Usa anomaly detection o ajusta por hora.

4. Correlación cliente-servidor cambia el debugging.

Cuando un usuario reporta "está lento", con trace ID puedo ir directo a la sesión específica y ver cada request que hizo, cuánto tardó, y qué pasó en el servidor.

5. Los RUM scripts pueden ralentizar tu app.

Carga el script lo más async posible. No lo metas en el critical path.

Costos a considerar

CloudWatch RUM cuesta $1 por cada 100k eventos. Una sesión típica tiene 20-50 eventos. Con 1M de usuarios activos mensuales, puedes gastar $200-500. X-Ray cuesta $5 por millón de traces. Haz los números antes de activar 100% sample.

Cierre

La observabilidad de frontend convierte "creo que hay un problema" en "sé exactamente qué, dónde y para quién". CloudWatch RUM más X-Ray te dan la cobertura completa. El setup inicial es 2-3 días de trabajo y te ahorra semanas en el año de estar adivinando qué pasa en producción.

En el próximo: Nuxt 3 en Lambda con Terraform para equipos que no usan CDK.

Top comments (0)