---
title: "Telemetry Ingest"
description: "Build the ingestion endpoint for @evlog/telemetry: the threat model, parseIngestBody validation, framework routes, storage, and rate limiting."
canonical_url: "https://www.evlog.dev/use-cases/telemetry/ingest"
---
# Telemetry Ingest

> Build the ingestion endpoint for @evlog/telemetry: the threat model, parseIngestBody validation, framework routes, storage, and rate limiting.

You ship a CLI. Users run it on their machines. You want to know *which commands run, how often they fail, and which versions are out there*, without building a separate analytics SDK or reading raw `argv`.

That means a **server endpoint** your CLI POSTs to. The local outbox is only there so short CI jobs and offline runs do not lose data before the POST succeeds.

## The security question (read this first)

<tip>

**CLI telemetry is not a vault.** You are collecting anonymous usage counters (command name, duration, outcome, a few numbers), not passwords, not file paths, not tokens. Design for that threat model and you do not need impossible client-side secrets.

</tip>

**"I put the ingest URL in my CLI. Isn't that a secret?"**

No. The URL lives in your npm package or binary. Anyone can read it with `strings`, by installing the package, or by watching network traffic. The same applies to an API key or `Authorization` header baked into the CLI is extractable, not a real barrier.

**"So can't anyone send fake events?"**

In theory, yes: someone could `curl` your endpoint with forged JSON. That is why you treat the endpoint as **semi-public** (like a browser analytics key) and defend on the server:

<table>
<thead>
  <tr>
    <th>
      What you might worry about
    </th>
    
    <th>
      Practical answer
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Random internet bots
    </td>
    
    <td>
      Validate the payload shape; reject garbage with <code>
        400
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Someone spamming fake <code>
        doctor
      </code>
      
       runs
    </td>
    
    <td>
      Rate-limit by IP; data is low-value counters, not money
    </td>
  </tr>
  
  <tr>
    <td>
      Forged <code>
        tool.name
      </code>
    </td>
    
    <td>
      Allowlist only your published tool names server-side
    </td>
  </tr>
  
  <tr>
    <td>
      Unexpected fields in <code>
        custom
      </code>
    </td>
    
    <td>
      Filter to the keys you declared — same list as <code>
        collect
      </code>
      
       on the CLI
    </td>
  </tr>
  
  <tr>
    <td>
      Double-counting on retry
    </td>
    
    <td>
      Dedupe on <code>
        idempotencyKey
      </code>
      
       when storing
    </td>
  </tr>
  
  <tr>
    <td>
      Leaking user secrets
    </td>
    
    <td>
      CLI never reads raw <code>
        argv
      </code>
      
      ; you only allowlisted flags and <code>
        telemetry.set()
      </code>
      
       counters
    </td>
  </tr>
</tbody>
</table>

**What "good enough" looks like:** validate every POST, store idempotently, rate-limit at the edge (CDN, API gateway, or middleware), and keep the payload boring (no PII by design). That is the same playbook as client-side analytics: not banking-grade auth, but reliable product insight.

<callout color="info" icon="i-lucide-shield-check">

`@evlog/telemetry` ships **parseIngestBody()** for your server, with the same validation rules documented here, so you do not copy-paste a validator from the docs. Pair it with your framework route and your database or evlog drain.

</callout>

## Step 1: Validate with `parseIngestBody()`

Mirror the CLI config on the server: same `name`, same custom keys you allow via `collect` / `telemetry.set()`.

```ts [lib/telemetry-ingest.ts]
import { parseIngestBody } from '@evlog/telemetry/ingest'

export const ingestOptions = {
  allowedTools: ['my-tool'],
  allowedCustomKeys: {
    'my-tool': ['checksFailed', 'checksWarn', 'itemsSynced'],
  },
} as const

export function parseTelemetryBody(raw: string) {
  return parseIngestBody(raw, ingestOptions)
}
```

`parseIngestBody()` checks batch size, JSON shape, `event: 'run'`, tool allowlist, envelope types, ISO timestamp, and strips `custom` keys you did not declare. Throws `IngestValidationError` on failure, so return `400` from your route.

## Step 2: Wire your route

Same contract for any framework: read the raw body, validate, store, return **204** so the CLI clears its outbox:

<framework-tabs>

```ts [Nitro]server/api/telemetry/ingest.post.ts
import { defineHandler } from 'nitro/h3'
import { readRawBody, setResponseStatus } from 'h3'
import { IngestValidationError } from '@evlog/telemetry/ingest'
import { parseTelemetryBody } from '~/lib/telemetry-ingest'
import { storeRunEvents } from '~/lib/telemetry-store'

export default defineHandler(async (event) => {
  const raw = await readRawBody(event, 'utf8')
  if (!raw) {
    setResponseStatus(event, 400)
    return { error: 'empty body' }
  }

  try {
    const events = parseTelemetryBody(raw)
    await storeRunEvents(events)
    setResponseStatus(event, 204)
    return null
  } catch (err) {
    setResponseStatus(event, err instanceof IngestValidationError ? 400 : 500)
    return { error: 'invalid payload' }
  }
})
```

```ts [Next.js App Router]app/api/telemetry/ingest/route.ts
import { IngestValidationError } from '@evlog/telemetry/ingest'
import { parseTelemetryBody } from '@/lib/telemetry-ingest'
import { storeRunEvents } from '@/lib/telemetry-store'

export async function POST(request: Request) {
  const raw = await request.text()
  try {
    const events = parseTelemetryBody(raw)
    await storeRunEvents(events)
    return new Response(null, { status: 204 })
  } catch (err) {
    const status = err instanceof IngestValidationError ? 400 : 500
    return Response.json({ error: 'invalid payload' }, { status })
  }
}
```

```ts [Hono]src/routes/telemetry.ts
import { Hono } from 'hono'
import { IngestValidationError } from '@evlog/telemetry/ingest'
import { parseTelemetryBody } from '../lib/telemetry-ingest'
import { storeRunEvents } from '../lib/telemetry-store'

const app = new Hono()

app.post('/api/telemetry/ingest', async (c) => {
  const raw = await c.req.text()
  try {
    const events = parseTelemetryBody(raw)
    await storeRunEvents(events)
    return c.body(null, 204)
  } catch (err) {
    const status = err instanceof IngestValidationError ? 400 : 500
    return c.json({ error: 'invalid payload' }, status)
  }
})

export default app
```

</framework-tabs>

## Step 3: Store idempotently

Retries and backlog drains can POST the same `idempotencyKey` twice. Upsert (or skip) so metrics stay correct:

```ts [lib/telemetry-store.ts]
import type { RunEvent } from '@evlog/telemetry'

export async function storeRunEvents(events: RunEvent[]): Promise<void> {
  for (const run of events) {
    await db.telemetryRun.upsert({
      where: { idempotencyKey: run.idempotencyKey },
      create: {
        tool: run.tool.name,
        version: run.tool.version,
        command: run.command,
        outcome: run.outcome,
        durationMs: run.durationMs,
        custom: run.custom,
        recordedAt: run.timestamp,
      },
      update: {},
    })
  }

  // Or forward into evlog's drain pipeline for Axiom / Datadog / OTLP:
  // await ingestWideEvents(events.map(run => ({ source: 'telemetry', ...run })))
}
```

## Step 4: Rate-limit at the edge

`parseIngestBody()` is your last line of schema defense, not your only one. Add rate limits where traffic enters (CDN, API gateway, or middleware), especially per IP. Optional: also bucket by `machineId` hash inside the handler if you need tighter control.

Non-2xx responses keep events in the user's outbox; they will retry on the next CLI invocation. Return **204** (or any `2xx`) only when storage succeeded.

---

- [Overview](https://www.evlog.dev/use-cases/telemetry/overview)
- [Setup](https://www.evlog.dev/use-cases/telemetry/setup)
- [Reference](https://www.evlog.dev/use-cases/telemetry/reference)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
