DEV Community

Harpreet Singh Seehra
Harpreet Singh Seehra

Posted on

Build a Geo-Distributed Call Logger with Per-Region KV Counters and SMS Alerting on Edge Compute

Build a Geo-Distributed Call Logger on Edge Compute

Log Telnyx Call Control events to a shared SQL database, track per-region call volume in Edge KV counters, and trigger SMS alerts when a region exceeds a threshold — all on Telnyx Edge Compute with the Agent SDK, in under 400 lines of TypeScript.

What You'll Build

A geo-distributed call logger that:

  • Receives Telnyx Call Control webhooks (call.initiated, call.answered, call.hangup)
  • Logs every call to a per-call SQL database
  • Increments per-region counters in Edge KV with rolling-window TTL
  • Detects the caller's region from the E.164 country code prefix
  • Sends an SMS alert to your ops phone when a region's call count exceeds a threshold
  • Exposes HTTP endpoints for querying call status, recent calls, and region statistics

The entire system runs on Telnyx Edge Compute — no external database, no separate alerting service, no cross-cloud latency.

Why This Architecture Matters

If you've ever tried to build call analytics across multiple regions, you've probably wired together:

  • A webhook receiver (Lambda function or small server)
  • A time-series database (InfluxDB, TimescaleDB, or DynamoDB)
  • A separate counter service (Redis)
  • An alerting pipeline (PagerDuty + SNS + a Lambda)
  • A dashboard (Grafana or a custom React app)

That's five services, five deploys, five billing lines, and cross-region latency at every hop. On Edge Compute with the Agent SDK, this collapses to one deploy.

Architecture

                    Telnyx Call Control
                           │
                           ▼
              POST /webhooks/voice
                           │
                           ▼
              ┌────────────────────┐
              │   index.ts         │
              │   (webhook router)  │
              └────┬───────────────┘
                   │
                   ▼
         ┌──────────────────────┐
         │  GeoLoggerAgent      │  (one actor per call)
         │                      │
         │  1. logCall()         │──► SQL DB: INSERT call record
         │                      │──► KV:     INCR region counter
         │  2. checkThreshold()  │──► compare count vs threshold
         │  3. alert()           │──► SMS via [telnyx] binding
         └──────────────────────┘
                   │
                   ▼
         ┌──────────────────────┐
         │  CallRegistry        │  (singleton actor)
         │  cross-call listing  │──► GET /calls
         └──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Each call gets its own GeoLoggerAgent actor instance with isolated state and its own SQL database. When call.hangup fires, the agent queues a 3-stage pipeline:

  1. logCall() — Inserts the call record into the actor's SQL DB and increments the region counter in KV
  2. checkThreshold() — Compares the post-increment region count against REGION_THRESHOLD
  3. alert() — Sends an SMS via the zero-credential [telnyx] binding — no API key needed in code

Prerequisites

  • Node.js 18+
  • Telnyx CLI (npm i -g @telnyx/cli)
  • A Telnyx account with a Call Control number, an API key, and a messaging-enabled number

Step 1: Configure the Edge Compute Bindings

The telnyx.toml file declares the bindings your agent needs:

name = "geo-distributed-call-logger"
main = "src/index.ts"
compatibility_date = "2026-05-01"

[[actors]]
binding = "GEO_LOGGER"
type = "GeoLoggerAgent"

[[actors]]
binding = "REGISTRY"
type = "CallRegistry"

[telnyx]
binding = "TELNYX"

[storage.kv.REGION_KV]
id = "<kv-namespace-uuid>"

[env_vars]
ALERT_PHONE = "+18005551234"
SENDER_PHONE = "+18005551234"
REGION_THRESHOLD = "100"
WINDOW_SECONDS = "3600"

[[secrets]]
binding = "TELNYX_API_KEY"
name = "TELNYX_API_KEY"
Enter fullscreen mode Exit fullscreen mode

Four bindings: two actor namespaces (GEO_LOGGER, REGISTRY), the zero-credential messaging binding (TELNYX), and the KV namespace (REGION_KV).

Step 2: Region Detection from E.164 Prefixes

const COUNTRY_TO_REGION: Record<string, string> = {
  "1": "us-east-1",      // US/Canada (+1)
  "44": "eu-west-1",      // UK (+44)
  "33": "eu-west-1",      // France (+33)
  "49": "eu-central-1",  // Germany (+49)
  "31": "eu-west-1",     // Netherlands (+31)
  "81": "ap-northeast-1", // Japan (+81)
  "82": "ap-northeast-1", // South Korea (+82)
  "86": "ap-east-1",     // China (+86)
  "91": "ap-south-1",    // India (+91)
  "61": "ap-southeast-1", // Australia (+61)
  "55": "sa-east-1",     // Brazil (+55)
};

export function detectRegion(phoneNumber: string): string {
  const digits = phoneNumber.replace(/^\+/, "");
  const cc2 = digits.slice(0, 2);
  const cc1 = digits.slice(0, 1);
  if (COUNTRY_TO_REGION[cc2]) return COUNTRY_TO_REGION[cc2];
  if (COUNTRY_TO_REGION[cc1]) return COUNTRY_TO_REGION[cc1];
  return "unknown";
}
Enter fullscreen mode Exit fullscreen mode

Country-code prefix to named region. 10 lines of code. For production, swap in a carrier lookup or Number Insight API for city-level precision.

Step 3: The GeoLoggerAgent Pipeline

The GeoLoggerAgent extends the Agent SDK's Agent class. Each call gets its own actor instance:

export class GeoLoggerAgent extends Agent<GeoLoggerEnv, GeoLoggerState> {
  protected override initialState(): GeoLoggerState {
    return {
      callControlId: "",
      fromNumber: "",
      toNumber: "",
      direction: "inbound",
      region: "unknown",
      status: "ringing",
      startedAt: 0,
      answeredAt: 0,
      endedAt: 0,
      durationSec: 0,
      logged: false,
      alertTriggered: false,
      regionCount: 0,
      threshold: 0,
      error: "",
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

When call.hangup fires, the agent queues the 3-stage pipeline:

async onHangup(): Promise<void> {
  const state = await this.getState();
  const endedAt = Date.now();
  const durationSec = state.answeredAt
    ? Math.round((endedAt - state.answeredAt) / 1000)
    : 0;
  await this.setState({ ...state, status: "hungup", endedAt, durationSec });
  await this.queue("logCall");
}
Enter fullscreen mode Exit fullscreen mode

Stage 1: logCall — SQL INSERT + KV INCR

async logCall(): Promise<void> {
  const state = await this.getState();
  try {
    // SQL: insert call record
    this.ctx.storage.sql.exec(
      `CREATE TABLE IF NOT EXISTS calls (
        call_control_id TEXT PRIMARY KEY,
        from_number     TEXT NOT NULL,
        to_number       TEXT NOT NULL,
        direction       TEXT NOT NULL,
        region          TEXT NOT NULL,
        duration_sec    INTEGER NOT NULL,
        started_at      INTEGER NOT NULL,
        ended_at        INTEGER NOT NULL,
        status          TEXT NOT NULL
      )`
    );
    this.ctx.storage.sql.exec(
      `INSERT OR REPLACE INTO calls
        (call_control_id, from_number, to_number, direction, region,
         duration_sec, started_at, ended_at, status)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      state.callControlId, state.fromNumber, state.toNumber,
      state.direction, state.region, state.durationSec,
      state.startedAt, state.endedAt, "completed"
    );

    // KV: increment region counter (rolling window)
    const windowSec = parseInt(this.env.WINDOW_SECONDS, 10) || 3600;
    const windowStart = Math.floor(Date.now() / 1000 / windowSec) * windowSec;
    const kvKey = `region:${state.region}:${windowStart}`;
    const currentStr = await this.env.REGION_KV.get(kvKey);
    const current = currentStr ? parseInt(currentStr, 10) : 0;
    const newCount = current + 1;
    await this.env.REGION_KV.put(kvKey, String(newCount), {
      expirationTtl: windowSec,
    });

    await this.setState({ ...state, logged: true, regionCount: newCount, status: "logged" });
    await this.queue("checkThreshold");
  } catch (e: unknown) {
    const msg = e instanceof Error ? e.message : String(e);
    await this.setState({ ...state, status: "error", error: `logCall: ${msg}` });
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things in one stage: SQL INSERT via ctx.storage.sql.exec() and KV INCR with rolling-window TTL.

Stage 2: checkThreshold

async checkThreshold(): Promise<void> {
  const state = await this.getState();
  if (state.regionCount >= state.threshold) {
    await this.setState({ ...state, status: "alerting", alertTriggered: true });
    await this.queue("alert");
  } else {
    await this.setState({ ...state, status: "done" });
  }
}
Enter fullscreen mode Exit fullscreen mode

Stage 3: alert — Zero-Credential SMS

async alert(): Promise<void> {
  const state = await this.getState();
  const smsText =
    `Geo Call Alert: region "${state.region}" hit ${state.regionCount} calls ` +
    `in the current window (threshold: ${state.threshold}). ` +
    `Last call: ${state.fromNumber}${state.toNumber}, ${state.durationSec}s. ` +
    `Check the dashboard.`;

  await this.env.TELNYX.messages.send({
    from: this.env.SENDER_PHONE,
    to: this.env.ALERT_PHONE,
    text: smsText,
  });

  await this.setState({ ...state, status: "done" });
}
Enter fullscreen mode Exit fullscreen mode

The SMS is sent via this.env.TELNYX.messages.send() — the zero-credential [telnyx] binding. No API key in code, no auth header. The binding carries auth from the TELNYX_API_KEY secret.

Step 4: The Webhook Handler

async function handleCallWebhook(req: Request, env: Env): Promise<Response> {
  const event = (await req.json()) as CallControlEvent;
  const data = event.data;
  const callControlId = data.call_control_id;
  const agent = env.GEO_LOGGER.idFromName(actorName(callControlId));

  switch (data.event_type) {
    case "call.initiated":
      await agent.onCallStart({
        callControlId,
        fromNumber: data.from,
        toNumber: data.to,
        direction: data.direction,
      });
      break;
    case "call.answered":
      await agent.onAnswered();
      break;
    case "call.hangup":
      await agent.onHangup(); // queues logCall → checkThreshold → alert
      break;
  }

  return Response.json({ received: true, eventType: data.event_type });
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Run and Test

Install and configure

cd geo-distributed-call-logger
npm install
cp .env.example .env
# Fill in TELNYX_API_KEY, SENDER_PHONE, ALERT_PHONE
Enter fullscreen mode Exit fullscreen mode

Run locally

npm start
Enter fullscreen mode Exit fullscreen mode

Simulate a call (no real phone needed)

curl -X POST http://localhost:3000/simulate \
  -H "Content-Type: application/json" \
  -d '{"from":"+31612345678","to":"+18005551234","duration":42}'
Enter fullscreen mode Exit fullscreen mode

Check region statistics

curl http://localhost:3000/regions/stats
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "threshold": 100,
  "windowSeconds": 3600,
  "regions": [
    { "region": "us-east-1", "count": 47, "windowStart": 1718928000 },
    { "region": "eu-west-1", "count": 103, "windowStart": 1718928000 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Trigger an alert

Set REGION_THRESHOLD=2, simulate 3 calls from the same region, and the third triggers an SMS to your phone.

Rolling-Window KV Counters

The KV key scheme is the key insight:

region:eu-west-1:1718928000   ← count for 14:00–15:00 window
region:eu-west-1:1718931600   ← count for 15:00–16:00 window
Enter fullscreen mode Exit fullscreen mode

Each key has expirationTtl: WINDOW_SECONDS. When the window expires, the key auto-deletes. No cron job, no cleanup Lambda, no stale counters. The windowStart is Math.floor(Date.now() / 1000 / windowSec) * windowSec — floor to the window boundary, so all calls in the same hour share the same key.

Why Actors?

Each call is isolated in its own GeoLoggerAgent actor instance:

  • No contention — 100 simultaneous calls get 100 actor instances
  • State survives retries — if a webhook is retried, the actor state persists
  • Per-call SQL DB — each actor has its own SQL instance, no database locking
  • The CallRegistry singleton aggregates across calls for the /calls endpoint

This is the Agent SDK's core value: stateful, durable execution without a database server.

API Endpoints

Method Path Description
POST /webhooks/voice Call Control webhook receiver
GET /status/:callId Get agent state for a specific call
GET /calls List recent calls (from registry actor)
GET /regions/stats Per-region call counts in the current window
GET /regions List supported regions and country codes
POST /simulate Simulate a call webhook (for testing)

Frequently Asked Questions

How does region detection work?

The sample maps E.164 country code prefixes to named regions. For production-grade geo-routing, replace detectRegion() with a carrier lookup or Number Insight API.

What happens when the rolling window expires?

The KV key auto-deletes via TTL. No cleanup code. The next call in the new window starts a fresh counter at 1.

Can I change the threshold at runtime?

Yes — REGION_THRESHOLD is an environment variable. Update it via telnyx-edge secret set and the next call picks up the new value.

Do I need a database server?

No. The SQL DB is built into the Edge Compute actor runtime — this.ctx.storage.sql.exec(). No connection string, no pool, no server to manage.

Resources


If you found this helpful, follow me for more Edge Compute and Voice API content. The full code is on GitHub — clone it, run it, break it. The /simulate endpoint means you don't even need a real call to see it work.

Top comments (0)