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
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 });
}
}
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>
);
}
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,
});
}
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>
);
}
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 });
}
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();
}
};
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,
}),
);
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,
});
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
-- 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
-- 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
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,
});
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)