Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
environment: ${{ matrix.environment }}
- name: Deploy Analytics Writer
uses: cloudflare/wrangler-action@v3
with:
workingDirectory: analytics-writer
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
environment: ${{ matrix.environment }}
- if: failure()
uses: slackapi/slack-github-action@v2.1.1
with:
Expand Down
124 changes: 124 additions & 0 deletions analytics-writer/README.md
Comment thread
juliangruber marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Analytics Writer

Cloudflare Worker that receives TTFB (Time To First Byte) metrics from the FilBeam bot and writes them to Analytics Engine.

## API

**POST /** - Send TTFB data

**Authentication Required:** Include the `X-Analytics-Auth` header with your pre-shared key.

**Headers:**
```
X-Analytics-Auth: your-secret-key
Content-Type: application/json
```

**Request Body:**
```json
{
"blobs": ["url", "location", "client", "cid"],
"doubles": [ttfb, status, bytes],
"indexes": ["optional-index-string"]
}
```

**Note:** Use either `index` (string) or `indexes` (array with max 1 item), not both.

**Response:**
```json
{"success": true}
```

**Error Response:**
```json
{
"success": false,
"error": "Error message"
}
```

## Data Structure

Each data point consists of:

- **Blobs** (strings) — Dimensions used for grouping and filtering
- **Doubles** (numbers) — Numeric values to record
- **Indexes** (strings) — Sampling key (single index only)

### Analytics Engine Limits

- **Blobs**: Maximum 20 items, total size must not exceed 16 KB
- **Doubles**: Maximum 20 items
- **Indexes**: Maximum 1 item, each index must not exceed 96 bytes
- **Data Points**: Maximum 25 data points per Worker invocation

### Field Details

- **blobs**: Array of strings containing dimensions for grouping and filtering:
- `url`: The URL that was requested
- `location`: Geographic location of the request
- `client`: Client identifier
- `cid`: Content identifier

- **doubles**: Array of numbers containing numeric values:
- `ttfb`: Time to first byte in milliseconds
- `status`: HTTP status code
- `bytes`: Number of bytes transferred

- **indexes**: Optional array with at most 1 item for sampling key

## Development

### Set up authentication

For local development, create a `.dev.vars` file in the `analytics-writer` directory:
```bash
echo "ANALYTICS_AUTH_KEY=your-local-dev-key" > .dev.vars
```

### Run locally
```bash
npm start
```

### Run tests
```bash
npm test
```

### Deploy

Set the authentication key as a secret (one time for each environment):
```bash
wrangler secret put ANALYTICS_AUTH_KEY --env calibration
wrangler secret put ANALYTICS_AUTH_KEY --env mainnet
```

Deploy to calibration:
```bash
npm run deploy:calibration
```

Deploy to mainnet:
```bash
npm run deploy:mainnet
```

## Configuration

The worker uses Cloudflare Analytics Engine with the dataset `ttfb_metrics`. The binding `analytics_engine` is configured in `wrangler.toml`.

## Error Handling

The worker includes comprehensive error handling:
- Validates authentication header (401 if missing or incorrect)
- Validates request method (POST only, returns 405)
- Validates JSON payload structure
- Validates array lengths for blobs and doubles
- Returns appropriate HTTP status codes
- Logs server errors (5xx) to console

## Security

The worker uses header-based authentication with a pre-shared key stored as a Cloudflare secret. All requests must include the `X-Analytics-Auth` header with the correct key value to access the API.
127 changes: 127 additions & 0 deletions analytics-writer/bin/analytics-writer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
export default {
/**
* @param {Request} request
* @param {{ analytics_engine: AnalyticsEngineDataset, ANALYTICS_AUTH_KEY: string }} env
*/
async fetch(request, env) {
/**
* @param {string} AUTH_HEADER_KEY Custom header to check for authentication
* @param {string} authKey Pre-shared authentication key from environment
*/
const AUTH_HEADER_KEY = 'X-Analytics-Auth'
const authKey = request.headers.get(AUTH_HEADER_KEY)

if (authKey !== env.ANALYTICS_AUTH_KEY) {
return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}

if (request.method !== 'POST') {
return new Response(JSON.stringify({ success: false, error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' }
})
}

try {
const data = await request.json()

// Validate data structure
if (!data.blobs || !data.doubles) {
return new Response(JSON.stringify({ success: false, error: 'Missing required fields' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}

if (!Array.isArray(data.blobs) || data.blobs.length !== 4) {
return new Response(JSON.stringify({ success: false, error: 'Invalid blobs array' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}

if (!Array.isArray(data.doubles) || data.doubles.length !== 3) {
return new Response(JSON.stringify({ success: false, error: 'Invalid doubles array' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}

// Validate Analytics Engine limits
// Blobs: max 20 items, max 16KB total size
if (data.blobs.length > 20) {
return new Response(JSON.stringify({ success: false, error: 'Too many blobs (max 20)' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}

const blobsSize = data.blobs.reduce((/** @type {number} */ total, /** @type {string} */ blob) => total + new TextEncoder().encode(blob).length, 0)
if (blobsSize > 16 * 1024) { // 16KB
return new Response(JSON.stringify({ success: false, error: 'Blobs too large (max 16KB)' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}

// Doubles: max 20 items
if (data.doubles.length > 20) {
return new Response(JSON.stringify({ success: false, error: 'Too many doubles (max 20)' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}

// Validate indexes array if provided
if (data.indexes !== undefined) {
if (!Array.isArray(data.indexes) || data.indexes.length > 1) {
return new Response(JSON.stringify({
success: false,
error: 'indexes must be an array with at most 1 item'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}

// Index: max 96 bytes
if (data.indexes.length > 0) {
const indexSize = new TextEncoder().encode(data.indexes[0]).length
if (indexSize > 96) {
return new Response(JSON.stringify({ success: false, error: 'Index too large (max 96 bytes)' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
}
}

// Write to Analytics Engine
// Blobs: dimensions for grouping and filtering [url, location, client, cid]
// Doubles: numeric values [ttfb, status, bytes]
// Indexes: sampling key (single index only)
const dataPoint = {
blobs: data.blobs,
doubles: data.doubles,
...(data.indexes && data.indexes.length > 0 && { indexes: data.indexes })
}

env.analytics_engine.writeDataPoint(dataPoint)

return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
}
}
18 changes: 18 additions & 0 deletions analytics-writer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@filbeam/analytics-writer",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build:types": "wrangler types",
"deploy:calibration": "wrangler deploy --env calibration",
"deploy:mainnet": "wrangler deploy --env mainnet",
"start": "wrangler dev",
"test": "vitest run"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.9.11",
"vitest": "3.1.4",
"wrangler": "^4.42.1"
}
}
Loading