-
Notifications
You must be signed in to change notification settings - Fork 7
Add Cloudflare Analytics Worker to collect TTFB (Time To First Byte) metrics from the FilBeam bot. #338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Add Cloudflare Analytics Worker to collect TTFB (Time To First Byte) metrics from the FilBeam bot. #338
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' } | ||
| }) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.