İçeriğe atla

AWS CDK Link Kısaltıcı Bölüm 2: Temel Fonksiyonlar & API Geliştirme

Yönlendirme motoru, analytics toplama ve API Gateway yapılandırması: günlük milyonlarca yönlendirme için performans optimizasyonları ve debugging stratejileri.

Ayhan Sipahi Ayhan Sipahi

Bir link kısaltıcı aslında bir redirect motorudur. Kısa kod araması ve HTTP 301 yanıtı, kritik gecikme bütçesindeki tek işlemlerdir; her ikisinin de yüksek eşzamanlılıkta bile kullanıcının anlık algıladığı eşiğin (yaklaşık 200ms) altında kalması gerekir. Bu sıcak yolun etrafındaki iş mantığı (analitik, hız sınırlama, link süresinin dolması, özel slug’lar) redirect’i engellememelidir; redirect handler’a eklenen her özelliğin bedeli doğrudan gecikme olarak ödenir.

Bu serinin Bölüm 1’i temeli kurdu (DynamoDB tablosu, API Gateway, temel Lambda). Sıra çekirdek işlevsellikte: DynamoDB cache’li redirect Lambda’sı, kısa kod oluşturma ve yönetme API’si, yan kanal üzerinden analitik olay yayını ve upstream servisler bozulduğunda redirect’i hızlı tutan hata yönetimi desenleri.

Redirect Motoru: Sıcak Yol#

Kullanıcı tarafında ürünün tamamı redirect handler’dır: tek arama, tek yanıt. Aşağıdaki implementasyon handler’ı tek bir DynamoDB okumasında tutar, geri kalan her şeyi yan kanala devreder:

// lambda/redirect.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';

import { NodeHttpHandler } from '@smithy/node-http-handler';

import { trackAnalytics } from './analytics';

const dynamodb = new DynamoDBClient({
  region: process.env.AWS_REGION,
  // Handler dışında oluşturulur: sıcak çağrılar aynı soketi yeniden kullanır
  maxAttempts: 3,
  requestHandler: new NodeHttpHandler({
    connectionTimeout: 1000,
    requestTimeout: 2000,
  })
});

export interface AnalyticsEvent {
  shortCode: string;
  timestamp: number;
  userAgent?: string;
  referer?: string;
  ip?: string;
  country?: string;
}

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  const startTime = Date.now();
  const shortCode = event.pathParameters?.shortCode;
  
  if (!shortCode) {
    return createErrorResponse(400, 'Short code gerekli');
  }

  try {
    // DynamoDB'den URL'i al
    const result = await dynamodb.send(new GetItemCommand({
      TableName: process.env.LINKS_TABLE_NAME!,
      Key: { shortCode: { S: shortCode } },
      ProjectionExpression: 'originalUrl, expiresAt, clickCount',
    }));

    if (!result.Item) {
      // Analytics için 404'leri takip et
      await trackAnalytics({
        shortCode,
        timestamp: Date.now(),
        userAgent: event.headers['User-Agent'],
        referer: event.headers['Referer'],
        ip: event.requestContext.identity?.sourceIp,
      }, 'NOT_FOUND');
      
      return createErrorResponse(404, 'Link bulunamadı');
    }

    const item = unmarshall(result.Item);
    
    // Expiration kontrolü
    if (item.expiresAt && Date.now() > item.expiresAt) {
      return createErrorResponse(410, 'Link süresi dolmuş');
    }

    // Analytics'i asenkron takip et (redirect'i bloklamasın)
    trackAnalytics({
      shortCode,
      timestamp: Date.now(),
      userAgent: event.headers['User-Agent'],
      referer: event.headers['Referer'],
      ip: event.requestContext.identity?.sourceIp,
    }, 'SUCCESS').catch(error => {
      console.error('Analytics tracking başarısız:', error);
      // Analytics başarısız olursa redirect'i başarısız etme
    });

    // CloudWatch Insights'ın responseTime üzerinden toplayabilmesi için yapısal log
    const responseTime = Date.now() - startTime;
    console.log(JSON.stringify({ event: 'RedirectProcessed', shortCode, responseTime }));

    return {
      statusCode: 301,
      headers: {
        Location: item.originalUrl,
        'Cache-Control': 'public, max-age=300', // 5 dakika
        'X-Response-Time': `${responseTime}ms`,
      },
      body: '',
    };

  } catch (error) {
    console.error(JSON.stringify({
      event: 'RedirectError',
      shortCode,
      message: error instanceof Error ? error.message : String(error),
    }));
    
    return createErrorResponse(500, 'Internal server hatası');
  }
};

function createErrorResponse(statusCode: number, message: string): APIGatewayProxyResult {
  return {
    statusCode,
    headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' },
    body: `<!DOCTYPE html><html><head><title>Link Error</title></head><body><h1>${statusCode === 404 ? 'Link Bulunamadı' : 'Hata'}</h1><p>${message}</p></body></html>`,
  };
}

Analytics: Business Intelligence Katmanı#

Bir kısaltıcıyı redirect’in ötesinde kullanışlı kılan şey analytics’tir: hangi kodlar trafik alıyor, tıklar nereden geliyor ve ne zaman geliyor. Toplama ayrı bir tabloya yazar, böylece redirect onu beklemez:

// lambda/analytics.ts
import { DynamoDBClient, PutItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
import crypto from 'crypto';

import type { AnalyticsEvent } from './redirect';

const dynamodb = new DynamoDBClient({ region: process.env.AWS_REGION });

function hashIP(ip: string): string {
  return crypto.createHash('sha256').update(ip + process.env.IP_SALT).digest('hex').substring(0, 16);
}

async function getCountryFromIP(ip?: string): Promise<string> {
  if (!ip) return 'unknown';

  // Placeholder: buraya bir geolocation araması (MaxMind veya CDN'inizin eklediği
  // ülke header'ı) koyun ve hatalarını burada ele alın.
  return 'US';
}

export async function trackAnalytics(
  event: AnalyticsEvent, 
  eventType: 'SUCCESS' | 'NOT_FOUND' = 'SUCCESS'
): Promise<void> {
  const timestamp = Date.now();
  const analyticsItem = {
    shortCode: event.shortCode,
    timestamp,
    eventType,
    userAgent: event.userAgent || 'unknown',
    referer: event.referer || 'direct',
    ip: hashIP(event.ip || ''), // Privacy-first yaklaşım
    country: await getCountryFromIP(event.ip),
    // Efficient query'ler için saatlik partition
    hourPartition: `${event.shortCode}#${Math.floor(timestamp / (1000 * 60 * 60))}`,
  };

  // Analytics tablosunda sakla
  await dynamodb.send(new PutItemCommand({
    TableName: process.env.ANALYTICS_TABLE_NAME!,
    Item: marshall(analyticsItem),
  }));

  // Ana kayıttaki tık sayısını güncelle (sadece başarılı tıklar için)
  if (eventType === 'SUCCESS') {
    await dynamodb.send(new UpdateItemCommand({
      TableName: process.env.LINKS_TABLE_NAME!,
      Key: { shortCode: { S: event.shortCode } },
      UpdateExpression: 'ADD clickCount :inc SET lastClickAt = :timestamp',
      ExpressionAttributeValues: {
        ':inc': { N: '1' },
        ':timestamp': { N: timestamp.toString() },
      },
    }));
  }
}

API Gateway: Ön Kapı#

CDK tanımı üç route’u üç handler’a bağlar ve onları koruyan stage seviyesindeki throttle değerlerini ayarlar:

// lib/api-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

interface ApiStackProps extends cdk.StackProps {
  redirectHandler: lambda.IFunction;
  createHandler: lambda.IFunction;
  analyticsHandler: lambda.IFunction;
}

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

    // Redirect ve yönetim route'ları için REST API
    const api = new apigateway.RestApi(this, 'LinkShortenerApi', {
      restApiName: 'Link Shortener Servisi',
      description: 'Production link kısaltıcı API',

      // 1 KB üzerindeki yanıtları sıkıştır. binaryMediaTypes'ı boş bırakın:
      // '*/*' değeri oluşturma API'sine gelen JSON body'leri base64'e çevirir.
      minimumCompressionSize: 1024,

      // CORS konfigürasyonu
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: ['GET', 'POST', 'OPTIONS'],
        allowHeaders: [
          'Content-Type',
          'X-Amz-Date',
          'Authorization',
          'X-Api-Key',
          'X-Amz-Security-Token',
        ],
        maxAge: cdk.Duration.hours(1),
      },

      // Stage seviyesinde metrik ve throttling
      deployOptions: {
        metricsEnabled: true,
        loggingLevel: apigateway.MethodLoggingLevel.INFO,
        dataTraceEnabled: false, // Prod'da kapalı: tüm request payload'ını loglar
        throttlingBurstLimit: 2000,
        throttlingRateLimit: 1000,
      },
    });

    // Validator var olan bir API'ye bağlıdır, bu yüzden ondan sonra oluşturulur
    const requestValidator = api.addRequestValidator('RequestValidator', {
      validateRequestBody: true,
      validateRequestParameters: true,
    });

    // Redirect route ekle: GET /{shortCode}
    const redirectIntegration = new apigateway.LambdaIntegration(props.redirectHandler, {
      proxy: true,
      allowTestInvoke: false, // Performans için test invoke'u devre dışı bırak
    });

    api.root.addResource('{shortCode}').addMethod('GET', redirectIntegration, {
      requestParameters: { 'method.request.path.shortCode': true },
    });

    // Link oluşturma API: POST /api/shorten
    const apiResource = api.root.addResource('api');
    const shortenResource = apiResource.addResource('shorten');

    const createIntegration = new apigateway.LambdaIntegration(props.createHandler, {
      proxy: true,
    });

    shortenResource.addMethod('POST', createIntegration, {
      requestModels: {
        'application/json': this.createRequestModel(api),
      },
      requestValidator,
    });

    // Analytics API: GET /api/analytics/{shortCode}
    const analyticsResource = apiResource.addResource('analytics');
    const analyticsCodeResource = analyticsResource.addResource('{shortCode}');
    analyticsCodeResource.addMethod('GET', new apigateway.LambdaIntegration(props.analyticsHandler));
  }

  private createRequestModel(api: apigateway.RestApi): apigateway.Model {
    return new apigateway.Model(this, 'ShortenRequestModel', {
      restApi: api,
      contentType: 'application/json',
      schema: {
        type: apigateway.JsonSchemaType.OBJECT,
        properties: {
          url: {
            type: apigateway.JsonSchemaType.STRING,
            pattern: '^https?://.+',
            minLength: 10,
            maxLength: 2048,
          },
          customCode: {
            type: apigateway.JsonSchemaType.STRING,
            pattern: '^[a-zA-Z0-9-_]{3,20}$',
          },
          expiresIn: {
            type: apigateway.JsonSchemaType.NUMBER,
            minimum: 3600,  // 1 saat minimum
            maximum: 31536000,  // 1 yıl maksimum
          },
        },
        required: ['url'],
        additionalProperties: false,
      },
    });
  }
}

Performansı Belirleyen Desenler#

Yukarıdaki koddaki üç karar, redirect gecikme bütçesinin büyük kısmını taşır.

1. Çağrılar Arası Bağlantı Yeniden Kullanımı#

DynamoDB client’ı handler’ın dışında oluşturulur; böylece aynı örnek ve HTTP agent’ı, sıcak execution environment’ın çağrıları arasında yaşamaya devam eder. Yeni bir environment’tan gelen ilk istek yine DNS çözümlemesi ve TLS handshake bedelini öder, sonraki her istek açık bağlantıyı yeniden kullanır. Client’ı handler’ın içine taşımak bu kazanımı çöpe atar ve her redirect’e bir handshake ekler.

2. Analytics’i Kritik Yolun Dışına Almak#

Redirect, DynamoDB yanıt verir vermez döner; analytics yazımı arkada çalışmaya bırakılır. Fire-and-forget deseninin gizlediği bir kural var: Lambda, yanıt yazıldıktan sonra execution environment’ı dondurur. Yani await etmediğiniz bir promise, o environment tekrar çağrılana kadar askıda kalabilir; environment önce geri alınırsa tamamen kaybolur. Arada bir tık kaybetmek kabul edilebilir değilse ya yazımı await edin ya da olayı bir SQS kuyruğuna bırakıp ayrı bir consumer’a kaydettirin.

3. Projection’lar ve Okuma Maliyeti#

ProjectionExpression, yanıtı redirect’in ihtiyaç duyduğu üç attribute ile sınırlar: originalUrl, expiresAt, clickCount. Bu, hat üzerindeki payload’ı küçültür; okuma kapasitesini değil. DynamoDB tüketilen okuma kapasitesini projection uygulanmadan önceki tam item boyutundan hesaplar, dolayısıyla geniş item’ları okumak pahalı kalmaya devam eder. Analytics sorguları bu okumayı genişletmek yerine ayrı bir GSI kullanır.

Production Hata Ayıklama#

CloudWatch Insights Sorguları#

Aşağıdaki iki sorgu, handler’ın logladığı JSON alanlarını okur; Insights yapısal log satırlarında bu alanları kendiliğinden keşfeder.

fields @timestamp, responseTime
| filter event = "RedirectProcessed"
| stats avg(responseTime) as avgMs, pct(responseTime, 95) as p95Ms by bin(5m)
fields @timestamp, shortCode
| filter event = "RedirectError"
| stats count(*) as errorCount by shortCode
| sort errorCount desc
| limit 20

Lambda Performans İzleme#

// lambda/redirect.ts, modül seviyesi: her execution environment'ta bir kez ayarlanır
let isColdStart = true;

// ...handler içinde, 301 dönmeden hemen önce:
console.log(JSON.stringify({
  event: 'RedirectProcessed',
  coldStart: isColdStart,
  responseTime: Date.now() - startTime,
  shortCode,
}));
isColdStart = false;

Redirect Motoru Testleri#

Handler doğrudan DynamoDB ile konuştuğu için bu testler aws-sdk-client-mock’a ya da abc123 ve expired kayıtlarıyla doldurulmuş yerel bir tabloya ihtiyaç duyar:

// tests/redirect.test.ts
import { APIGatewayProxyEvent } from 'aws-lambda';

import { handler } from '../lambda/redirect';

function createAPIGatewayEvent(shortCode: string): APIGatewayProxyEvent {
  return {
    pathParameters: { shortCode },
    headers: {},
    requestContext: { identity: {} },
  } as unknown as APIGatewayProxyEvent;
}

describe('Redirect Handler', () => {
  beforeEach(() => {
    process.env.LINKS_TABLE_NAME = 'test-links';
    process.env.ANALYTICS_TABLE_NAME = 'test-analytics';
  });

  test('should redirect to original URL', async () => {
    const result = await handler(createAPIGatewayEvent('abc123'));
    expect(result.statusCode).toBe(301);
    expect(result.headers?.Location).toBe('https://example.com');
    expect(result.headers?.['Cache-Control']).toBe('public, max-age=300');
  });

  test('should handle expired links gracefully', async () => {
    const result = await handler(createAPIGatewayEvent('expired'));
    expect(result.statusCode).toBe(410);
    expect(result.body).toContain('expired');
  });
});

Sonraki Adımlar#

Yukarıdaki tek okumalı redirect, sıcak yol partition key üzerinde bir GetItem olarak kaldığı sürece geçerlidir. Orijinal URL’e göre arama, kullanıcı başına link listesi veya redirect üzerinde ikinci bir tablo okuması gecikme bütçesini bozar; bunların yeri oluşturma API’sinin arkası ya da yalnızca analytics’in sorguladığı bir GSI’dır.

Bölüm 3, servisi spam vektörü olmaktan koruyan katmanları ekler: input validation, rate limiting, WAF kuralları ve sertifikalarıyla birlikte custom domain kurulumu.

Kaynaklar#

AWS CDK Link Kısaltıcı: Sıfırdan Production'a

AWS CDK, Node.js Lambda ve DynamoDB ile production-grade bir link kısaltma servisi kurulumu hakkında 5 bölümlük kapsamlı seri. Gerçek production hikayeleri, performans optimizasyonu ve maliyet yönetimi dahil.

İlerleme 2/5 yazı tamamlandı

İlgili yazılar