feat: add cloudflare-metrics worker for graphql analytics export (#28)

This commit is contained in:
Zack Pollard
2026-04-22 18:22:19 +01:00
committed by GitHub
parent a7f7700834
commit ebeb6d664c
72 changed files with 29382 additions and 14 deletions
+11 -11
View File
@@ -18,13 +18,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: '.nvmrc'
- name: Run pnpm install
@@ -34,7 +34,7 @@ jobs:
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: build-output
path: 'dist'
@@ -52,18 +52,18 @@ jobs:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_TF_DEV_ENV }}
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Get build artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: 'build-output'
path: '${{ github.workspace }}/dist'
- name: Install 1Password CLI
uses: 1password/install-cli-action@9a0c9dd934086b7ab1d90115d455bda1c53c2bdb # v2.0.2
uses: 1password/install-cli-action@8d006a0d0a4fd505af7f7ce589e7f768385ff5e4 # v3.0.0
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@697a75e2c3186d3c037c2c159855cf2d566542ba # use-mise-action-0.0.1
@@ -100,7 +100,7 @@ jobs:
- name: Comment preview URLs
if: ${{ github.event_name == 'pull_request' }}
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
uses: actions-cool/maintain-one-comment@772214ac4bff3c578c76eed4ac19d08dc787c050 # v3.2.1
with:
number: ${{ github.event.number }}
body: ${{ steps.preview-urls.outputs.body }}
@@ -120,18 +120,18 @@ jobs:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_TF_PROD_ENV }}
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Get build artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: 'build-output'
path: '${{ github.workspace }}/dist'
- name: Install 1Password CLI
uses: 1password/install-cli-action@9a0c9dd934086b7ab1d90115d455bda1c53c2bdb # v2.0.2
uses: 1password/install-cli-action@8d006a0d0a4fd505af7f7ce589e7f768385ff5e4 # v3.0.0
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@697a75e2c3186d3c037c2c159855cf2d566542ba # use-mise-action-0.0.1
+66 -3
View File
@@ -15,11 +15,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: '.nvmrc'
@@ -41,3 +43,64 @@ jobs:
- name: Run unit tests
run: pnpm run test
if: ${{ !cancelled() }}
integration-test-cloudflare-metrics:
name: Integration Test (cloudflare-metrics)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Detect relevant changes
id: changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
filters: |
cloudflare-metrics:
- 'apps/cloudflare-metrics/**'
- '.github/workflows/test.yml'
- name: Setup pnpm
if: steps.changes.outputs.cloudflare-metrics == 'true' || github.event_name != 'pull_request'
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
if: steps.changes.outputs.cloudflare-metrics == 'true' || github.event_name != 'pull_request'
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: '.nvmrc'
- name: Run pnpm install
if: steps.changes.outputs.cloudflare-metrics == 'true' || github.event_name != 'pull_request'
run: pnpm install --frozen-lockfile
- name: Install 1Password CLI
if: steps.changes.outputs.cloudflare-metrics == 'true' || github.event_name != 'pull_request'
uses: 1password/install-cli-action@8d006a0d0a4fd505af7f7ce589e7f768385ff5e4 # v3.0.0
- name: Check integration credentials are available
id: creds
if: steps.changes.outputs.cloudflare-metrics == 'true' || github.event_name != 'pull_request'
continue-on-error: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_TF_DEV_ENV }}
run: |
if op run --env-file=apps/cloudflare-metrics/.integration.env -- sh -c 'test -n "$CLOUDFLARE_API_TOKEN" && test -n "$CLOUDFLARE_ACCOUNT_ID"' 2>/dev/null; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "available=false" >> "$GITHUB_OUTPUT"
echo "::warning::Skipping integration test — credentials not configured in 1Password"
fi
- name: Run integration tests
if: >-
(steps.changes.outputs.cloudflare-metrics == 'true' || github.event_name != 'pull_request')
&& steps.creds.outputs.available == 'true'
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_TF_DEV_ENV }}
run: |
op run \
--env-file=apps/cloudflare-metrics/.integration.env \
-- pnpm --filter @immich-services/cloudflare-metrics run test:integration
+4
View File
@@ -14,6 +14,9 @@ build/
.wrangler/
.dev.vars
# Local-only env files (the tracked one is `deployment/.env`)
deployment/.env.*
# Logs
logs/
*.log
@@ -42,3 +45,4 @@ npm-debug.log*
# Misc
.cache/
tmp/
.claude/
+12
View File
@@ -0,0 +1,12 @@
# 1Password-templated env file loaded by `op run` in CI.
#
# To run integration tests locally:
# op run --env-file=apps/cloudflare-metrics/.integration.env -- \
# pnpm --filter @immich-services/cloudflare-metrics run test:integration
#
# The 1Password item and field names below are placeholders — set them up
# in the service account's vault before the CI job can run. Both need to
# map to a Cloudflare API token that has `Account Analytics:Read` and
# `Zone:Read` for the dev account.
CLOUDFLARE_API_TOKEN="op://services-cf-workers-dev/cloudflare-metrics-integration/api_token"
CLOUDFLARE_ACCOUNT_ID="op://services-cf-workers-dev/cloudflare-metrics-integration/account_id"
+190
View File
@@ -0,0 +1,190 @@
# cloudflare-metrics
Cloudflare Worker that pulls analytics from the Cloudflare GraphQL API every minute, enriches with resource names from the REST API, and writes to VictoriaMetrics as InfluxDB line protocol.
## Architecture
```
Cloudflare GraphQL Analytics API ──► CloudflareGraphQLClient
Cloudflare REST API (D1/queues/zones) ──► CloudflareRestClient
│
▼
CloudflareMetricsCollector
├─ ResourceCacheService (id → name lookups)
├─ emit.ts (row → Metric translation)
└─ graphql-builders.ts (query construction)
│
▼
InfluxMetricsProvider ──► VictoriaMetrics /write
```
## Collection window
| Setting | Value | Why |
| ------- | ----------- | ------------------------------------------------------------- |
| Cron | `* * * * *` | Every minute |
| Lag | 5 min | Cloudflare's analytics pipeline is 2–5 min behind real time |
| Window | 3 min | Overlaps consecutive ticks so a missed cron doesn't drop data |
| Dedup | Free | VictoriaMetrics dedupes on `(series, timestamp)` |
## Datasets
78 datasets across account-scope and zone-scope, covering:
- **Workers**: invocations, subrequests, overview, scheduled (client-side aggregated), analytics engine, builds, VPC, placement, workflows
- **D1**: queries (summary + detail with p50/p95/p99), storage
- **R2**: operations, storage, sippy
- **KV**: operations, storage
- **Durable Objects**: invocations, periodic, storage, SQL storage, subrequests
- **Queues**: operations, backlog, consumer concurrency
- **Hyperdrive**: queries (with count), pool sizes
- **HTTP (zone-scope)**: overview, detail (per-zone batched), cache reserve, logpush health
- **Pages Functions**: invocations with CPU/duration p50/p99
- **AI**: inference, gateway (requests/cache/errors/size), search, autoRAG
- **Vectorize**: operations, queries, storage, writes
- **Browser**: rendering API/sessions/time/events, isolation sessions/actions
- **Stream/Video/Calls**: minutes viewed, CMCD, buffer/playback/quality events, live input, realtime kit, calls usage/TURN
- **Images/toMarkdown**: request counts, conversion stats
- **RUM**: pageload, performance (with FCP/page-load p50/p95/p99), web vitals (CLS/FCP/FID/INP/LCP/TTFB averages + p75/p95)
- **Pipelines**: ingestion, delivery, operator, sink
- **Containers, Turnstile**
- **DNS, Email Routing/Sending, DMARC** (zone-scope)
- **API Gateway sessions, Workers Zone invocations/subrequests** (zone-scope)
See `src/datasets.ts` for the full registry. Each dataset declares its GraphQL field, dimensions, aggregation blocks, tag mappings, and field mappings.
### Skipped datasets
| Dataset | Reason |
| ------------------------------------- | -------------------------------------------------------------------------- |
| `firewallEventsAdaptiveGroups` | Plan-gated (Business/Enterprise only) |
| `cdnNetworkAnalyticsAdaptiveGroups` | Plan-gated |
| `zarazTrack/TriggersAdaptiveGroups` | Incompatible filter shape (`datetimeMinute_geq` instead of `datetime_geq`) |
| `cloudchamberMetricsAdaptiveGroups` | Duplicate schema of `containersMetrics` |
| `cacheReserveRequestsAdaptiveGroups` | Plan-gated (zone-scope) |
| `healthCheckEventsAdaptiveGroups` | Plan-gated (zone-scope) |
| `loadBalancingRequestsAdaptiveGroups` | Plan-gated (zone-scope) |
| `nelReportsAdaptiveGroups` | Plan-gated (zone-scope) |
| `pageShieldReportsAdaptiveGroups` | Plan-gated (zone-scope) |
| `waitingRoomAnalyticsAdaptiveGroups` | Plan-gated (zone-scope) |
## Query batching
Cloudflare Workers caps subrequests at 50 per invocation. Account-scope datasets are batched into chunks of 25 using GraphQL aliases. Zone-scope datasets are batched across all zones in a single request per dataset.
| Metric | Cold start | Warm (cached) |
| ------------------------------ | ---------- | -------------------- |
| REST lookups (D1/queues/zones) | 3 | 0 (10-min TTL cache) |
| GraphQL account batches | 3 chunks | 3 chunks |
| GraphQL date-granularity batch | 1 | 1 |
| Zone-scope datasets | ~11 | ~11 |
| Metric flush | 1 | 1 |
| **Total subrequests** | **~19** | **~16** |
## Resource name enrichment
IDs in the analytics API are enriched with human-readable names via REST lookups:
- `database_name` on `cf_d1_*` metrics (from `/accounts/{id}/d1/database`)
- `queue_name` on `cf_queue_*` metrics (from `/accounts/{id}/queues`)
- `zone_name` on `cf_http_*` metrics (from `/zones?account.id={id}` + per-zone fallback for Pages projects)
Module-level caches survive across isolate invocations (typically 10+ minutes on the paid plan). A 10-minute TTL triggers periodic re-fetches. Failed lookups fall back to stale cached names.
## Self-telemetry
The worker emits its own health metrics alongside the Cloudflare data:
- `cloudflare_metrics_cron_summary` — datasets/points/errors per tick
- `cloudflare_metrics_cron_error{reason}` — early-exit errors
- `cloudflare_metrics_collector_dataset{dataset,status}` — per-dataset rows/points/duration/errors
- `cloudflare_metrics_resource_lookup{resource,status}` — REST lookup outcomes
- `cloudflare_metrics_graphql_client` — requests/error_responses per tick
- `cloudflare_metrics_flush{status}` — bytes/duration/pending buffers (from previous tick)
- `cloudflare_metrics_http_response{method,path,status}` — HTTP handler counts
- `cloudflare_metrics_handle_request` — handler duration/invocation
## Dashboards
20 Grafana dashboards managed via Terraform, in `deployment/.../dashboards/`:
| Dashboard | Template variables |
| ---------------------------------------------- | ------------------------------- |
| Account Overview | — |
| Workers | `$script_name`, `$status` |
| Workers Scheduled | `$script_name`, `$cron` |
| D1 | `$database_name` |
| R2 | `$bucket_name` |
| KV | `$namespace_id` |
| Durable Objects | `$script_name`, `$namespace_id` |
| Queues | `$queue_name` |
| Hyperdrive | `$config_id` |
| HTTP / Zones | `$zone_name` |
| Pages Functions | `$script_name` |
| AI, Vectorize, Browser, Stream, RUM, Pipelines | — |
| DNS | `$zone_name` |
| Email | `$zone_name` |
| Exporter Health | — |
## Alerts
7 Grafana alert rules in `deployment/.../alerts.tf`, all linked to the exporter-health dashboard:
| Rule | Condition | Severity |
| ---------------------------------- | ---------------------------------- | -------- |
| Collector Not Running | No `cron_summary_datasets` for 10m | 1 |
| Collector Cron Error | `cron_error_count > 0` in 10m | 1 |
| Dataset Errors Sustained | `>10` errors in 15m | 3 |
| Metrics Flush Failing | `>3` flush errors in 15m | 1 |
| Pending Flush Buffer Growing | `>3` stashed bodies for 10m | 3 |
| GraphQL Subrequest Budget Near Cap | `>40` requests/tick for 10m | 3 |
| GraphQL Error Responses Sustained | `>5` error responses in 15m | 3 |
## File structure
```
src/
index.ts Entry point (wires handlers)
handlers/
http.ts /health endpoint
scheduled.ts Cron handler (collect + flush)
collector.ts Orchestration (collectAll → batched account/zone fetches)
resource-cache.ts REST resource lookups + module-level caching
emit.ts DatasetRow → Metric translation + tag enrichment
graphql-client.ts CloudflareGraphQLClient (HTTP transport + chunking)
graphql-builders.ts Query construction (pure functions)
cloudflare-api.ts CloudflareRestClient (D1/queues/zones REST)
metrics.ts CloudflareMetricsRepository facade
metric.ts Metric data class
metric-providers.ts InfluxMetricsProvider + HeaderMetricsProvider
flush-state.ts Retry buffer + last-flush stats (module-level state)
datasets.ts 78 DatasetQuery definitions
types.ts Shared types
deferred.ts DeferredRepository (waitUntil helper)
monitor.ts monitorAsyncFunction (duration/invocation wrapper)
```
## Development
```bash
pnpm run dev # wrangler dev (local)
pnpm run test # 71 unit tests
pnpm run test:integration # live API tests (needs CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID)
pnpm run check # tsc --noEmit
pnpm run build # wrangler deploy --dry-run
```
Local `.dev.vars`:
```
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_ACCOUNT_ID=...
VMETRICS_API_TOKEN=...
ENVIRONMENT=dev
```
## Infrastructure
- **Worker**: `apps/cloudflare-metrics/` — TypeScript, Wrangler, every-minute cron
- **Terraform**: `deployment/modules/cloudflare/workers/cloudflare-metrics/` — worker, version, deployment, cron trigger, dashboards, alerts, and a scoped API token with Account Analytics Read + D1 Read + Queues Read + Zone Read
- **CI**: unit tests on every PR, path-filtered integration tests (gated on 1Password credentials), build + deploy-dev on PR, deploy-prod on merge to main
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@immich-services/cloudflare-metrics",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"build": "wrangler deploy --dry-run --outdir ../../dist/cloudflare-metrics",
"tail": "wrangler tail",
"test": "vitest run --exclude 'src/integration.test.ts'",
"test:integration": "vitest run --config vitest.integration.config.ts",
"check": "tsc --noEmit"
},
"dependencies": {
"@influxdata/influxdb-client": "^1.34.0"
}
}
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import { CloudflareRestClient, CloudflareRestError } from './cloudflare-api.js';
describe('CloudflareRestClient', () => {
it('paginates through listD1Databases', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: true,
result: [{ uuid: 'a', name: 'one' }],
result_info: { page: 1, per_page: 100, total_pages: 2, count: 1, total_count: 2 },
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: true,
result: [{ uuid: 'b', name: 'two' }],
result_info: { page: 2, per_page: 100, total_pages: 2, count: 1, total_count: 2 },
}),
{ status: 200 },
),
);
const client = new CloudflareRestClient('tok', 'https://example.com', fetchMock as unknown as typeof fetch);
const dbs = await client.listD1Databases('acct');
expect(dbs).toEqual([
{ uuid: 'a', name: 'one' },
{ uuid: 'b', name: 'two' },
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(firstCall[0]).toContain('/accounts/acct/d1/database?page=1');
expect((firstCall[1].headers as Record<string, string>).Authorization).toBe('Bearer tok');
});
it('throws CloudflareRestError on non-OK responses', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('nope', { status: 403 }));
const client = new CloudflareRestClient('tok', 'https://example.com', fetchMock as unknown as typeof fetch);
await expect(client.listQueues('acct')).rejects.toBeInstanceOf(CloudflareRestError);
});
it('keeps the existing query string when listing zones', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
success: true,
result: [{ id: 'z1', name: 'example.com' }],
result_info: { page: 1, per_page: 100, total_pages: 1, count: 1, total_count: 1 },
}),
{ status: 200 },
),
);
const client = new CloudflareRestClient('tok', 'https://example.com', fetchMock as unknown as typeof fetch);
await client.listZones('acct');
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(call[0]).toContain('/zones?account.id=acct&page=1');
});
it('returns null from getZone when the API responds with 404', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 404 }));
const client = new CloudflareRestClient('tok', 'https://example.com', fetchMock as unknown as typeof fetch);
const zone = await client.getZone('does-not-exist');
expect(zone).toBeNull();
});
it('returns the zone payload from getZone on success', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
success: true,
result: { id: 'z1', name: 'pages.example.com' },
}),
{ status: 200 },
),
);
const client = new CloudflareRestClient('tok', 'https://example.com', fetchMock as unknown as typeof fetch);
const zone = await client.getZone('z1');
expect(zone).toEqual({ id: 'z1', name: 'pages.example.com' });
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(call[0]).toBe('https://example.com/zones/z1');
});
});
@@ -0,0 +1,118 @@
const DEFAULT_BASE_URL = 'https://api.cloudflare.com/client/v4';
const DEFAULT_PAGE_SIZE = 100;
export interface D1Database {
uuid: string;
name: string;
}
export interface Queue {
queue_id: string;
queue_name: string;
}
export interface Zone {
id: string;
name: string;
}
interface CloudflareListResponse<T> {
result: T[] | null;
result_info?: {
page: number;
per_page: number;
total_pages: number;
count: number;
total_count: number;
};
success: boolean;
errors?: Array<{ code: number; message: string }>;
}
export class CloudflareRestError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = 'CloudflareRestError';
}
}
export interface ICloudflareRestClient {
listD1Databases(accountId: string): Promise<D1Database[]>;
listQueues(accountId: string): Promise<Queue[]>;
listZones(accountId: string): Promise<Zone[]>;
getZone(zoneId: string): Promise<Zone | null>;
}
export class CloudflareRestClient implements ICloudflareRestClient {
constructor(
private readonly apiToken: string,
private readonly baseUrl: string = DEFAULT_BASE_URL,
private readonly fetchImpl?: typeof fetch,
) {}
async listD1Databases(accountId: string): Promise<D1Database[]> {
return this.listPaginated<D1Database>(`/accounts/${encodeURIComponent(accountId)}/d1/database`);
}
async listQueues(accountId: string): Promise<Queue[]> {
return this.listPaginated<Queue>(`/accounts/${encodeURIComponent(accountId)}/queues`);
}
async listZones(accountId: string): Promise<Zone[]> {
return this.listPaginated<Zone>(`/zones?account.id=${encodeURIComponent(accountId)}`);
}
async getZone(zoneId: string): Promise<Zone | null> {
const doFetch = this.fetchImpl ?? globalThis.fetch;
const url = `${this.baseUrl}/zones/${encodeURIComponent(zoneId)}`;
const response = await doFetch(url, {
headers: { Authorization: `Bearer ${this.apiToken}` },
});
if (response.status === 404) {
await response.body?.cancel();
return null;
}
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new CloudflareRestError(
`Cloudflare REST error (${response.status}) for /zones/${zoneId}: ${body.slice(0, 200)}`,
response.status,
);
}
const payload = (await response.json()) as { result: Zone | null };
return payload.result ?? null;
}
private async listPaginated<T>(path: string): Promise<T[]> {
const all: T[] = [];
let page = 1;
const separator = path.includes('?') ? '&' : '?';
const doFetch = this.fetchImpl ?? globalThis.fetch;
for (;;) {
const url = `${this.baseUrl}${path}${separator}page=${page}&per_page=${DEFAULT_PAGE_SIZE}`;
const response = await doFetch(url, {
headers: { Authorization: `Bearer ${this.apiToken}` },
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new CloudflareRestError(
`Cloudflare REST error (${response.status}) for ${path}: ${body.slice(0, 200)}`,
response.status,
);
}
const payload = (await response.json()) as CloudflareListResponse<T>;
if (payload.result) {
all.push(...payload.result);
}
const info = payload.result_info;
if (!info || page >= (info.total_pages ?? 1)) {
break;
}
page++;
}
return all;
}
}
@@ -0,0 +1,658 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { CloudflareMetricsCollector } from './collector.js';
import {
D1_QUERIES,
DURABLE_OBJECTS_PERIODIC,
DURABLE_OBJECTS_STORAGE,
HTTP_REQUESTS_OVERVIEW,
HYPERDRIVE_POOL,
QUEUE_BACKLOG,
QUEUE_OPERATIONS,
R2_OPERATIONS,
WORKERS_INVOCATIONS,
} from './datasets.js';
import type { CloudflareGraphQLClient } from './graphql-client.js';
import { type CloudflareMetricsRepository } from './metrics.js';
import { __resetResourceCachesForTests } from './resource-cache.js';
import {
asGraphQLClient,
buildMetricsRepo,
FakeGraphQLClient,
FakeRestClient,
RecordingProvider,
ThrowingRestClient,
} from './test-helpers.js';
import type { DatasetQuery, DatasetRow } from './types.js';
describe('CloudflareMetricsCollector', () => {
let provider: RecordingProvider;
let metrics: CloudflareMetricsRepository;
beforeEach(() => {
provider = new RecordingProvider();
metrics = buildMetricsRepo(provider);
__resetResourceCachesForTests();
});
it('computes a lagged query range so late-arriving buckets are included', () => {
const now = new Date('2026-04-10T12:00:00Z');
const collector = new CloudflareMetricsCollector(asGraphQLClient(new FakeGraphQLClient({})), 'acct', metrics, {
now: () => now,
lagMs: 2 * 60 * 1000,
windowMs: 5 * 60 * 1000,
});
const range = collector.getRange();
expect(range.end).toEqual(new Date('2026-04-10T11:58:00Z'));
expect(range.start).toEqual(new Date('2026-04-10T11:53:00Z'));
});
it('emits a workers invocations metric with snake_case tags and fields, scaled durations', async () => {
const row: DatasetRow = {
dimensions: {
datetimeMinute: '2026-04-10T12:00:00Z',
scriptName: 'version-api-prod',
status: 'success',
scriptVersion: 'abc',
usageModel: 'standard',
},
sum: {
requests: 9,
errors: 0,
subrequests: 9,
clientDisconnects: 0,
cpuTimeUs: 7474,
duration: 0.165_879,
wallTime: 1_327_032,
responseBodySize: 522,
},
max: {
cpuTime: 1400,
duration: 0.037_329_5,
wallTime: 298_636,
requestDuration: 1579,
responseBodySize: 58,
},
quantiles: {
cpuTimeP50: 730,
cpuTimeP99: 1400,
durationP50: 0.015_286_75,
durationP99: 0.037_329_5,
wallTimeP50: 122_294,
wallTimeP99: 298_636,
responseBodySizeP99: 58,
},
};
const client = new FakeGraphQLClient({ workers_invocations: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct-xyz', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
const [result] = await collector.collectAll([WORKERS_INVOCATIONS]);
expect(result.points).toBe(1);
expect(result.rows).toBe(1);
expect(result.error).toBeUndefined();
const exported = provider.metrics.find((m) => m.name === 'cf_workers_invocations');
expect(exported).toBeDefined();
expect(exported?.tags.get('script_name')).toBe('version-api-prod');
expect(exported?.tags.get('status')).toBe('success');
expect(exported?.tags.get('account_id')).toBe('acct-xyz');
expect(exported?.exportTimestamp).toEqual(new Date('2026-04-10T12:00:00Z'));
expect(exported?.fields.get('requests')).toEqual({ value: 9, type: 'int' });
// Duration in seconds (0.165879) × 1000 = 165.879 ms, stored as float
expect(exported?.fields.get('duration_ms_sum')).toEqual({ value: 165.879, type: 'float' });
expect(exported?.fields.get('duration_ms_p99')).toEqual({ value: 37.3295, type: 'float' });
expect(exported?.fields.get('cpu_time_us_p99')).toEqual({ value: 1400, type: 'int' });
});
it('coerces numeric dimensions to string tags', async () => {
const row: DatasetRow = {
dimensions: {
datetimeMinute: '2026-04-10T12:00:00Z',
bucketName: 'my-bucket',
actionType: 'GetObject',
actionStatus: 'success',
responseStatusCode: 200,
storageClass: 'Standard',
},
sum: { requests: 5, responseBytes: 100, responseObjectSize: 100 },
};
const client = new FakeGraphQLClient({ r2_operations: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([R2_OPERATIONS]);
const exported = provider.metrics.find((m) => m.name === 'cf_r2_operations');
expect(exported?.tags.get('response_status_code')).toBe('200');
expect(exported?.tags.get('bucket_name')).toBe('my-bucket');
});
it('drops rows with neither a timestamp dimension nor any numeric field', async () => {
const row: DatasetRow = { dimensions: { scriptName: 'no-timestamp' }, sum: { readQueries: 1 } };
const client = new FakeGraphQLClient({ d1_queries: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
const [result] = await collector.collectAll([D1_QUERIES]);
expect(result.rows).toBe(1);
expect(result.points).toBe(0);
expect(provider.metrics.some((m) => m.name === 'cf_d1_queries')).toBe(false);
});
it('skips null-valued fields rather than emitting zeros', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', databaseId: 'db1', databaseRole: 'primary' },
sum: { readQueries: null, writeQueries: 0, rowsRead: 10, rowsWritten: null, queryBatchResponseBytes: 100 },
avg: { queryBatchTimeMs: null, queryBatchResponseBytes: 100, sampleInterval: 1 },
};
const client = new FakeGraphQLClient({ d1_queries: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([D1_QUERIES]);
const exported = provider.metrics.find((m) => m.name === 'cf_d1_queries');
expect(exported?.fields.has('read_queries')).toBe(false);
expect(exported?.fields.has('rows_written')).toBe(false);
expect(exported?.fields.get('write_queries')).toEqual({ value: 0, type: 'int' });
expect(exported?.fields.get('rows_read')).toEqual({ value: 10, type: 'int' });
expect(exported?.fields.get('query_duration_ms_avg')).toBeUndefined();
expect(exported?.fields.get('sample_interval')).toEqual({ value: 1, type: 'float' });
});
it('records a per-dataset collector metric tagged with success/error', async () => {
const client = new FakeGraphQLClient({ workers_invocations: [] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([WORKERS_INVOCATIONS]);
const observed = provider.metrics.find(
(m) => m.name === 'cloudflare_metrics_collector_dataset' && m.tags.get('dataset') === 'workers_invocations',
);
expect(observed?.tags.get('status')).toBe('success');
expect(observed?.fields.get('rows')).toEqual({ value: 0, type: 'int' });
});
it('reports per-dataset errors from a batch response without aborting others', async () => {
// Simulates a partial GraphQL response where one field is rejected and
// the others still return data. The batched client surfaces the error
// in `batchResult.errors[dataset.key]`, the collector maps it to a
// collector_dataset{status=error} self-metric, and the other datasets
// still get emitted.
class PartialErrorClient {
// eslint-disable-next-line @typescript-eslint/require-await
async fetchAccountBatch(
_accountTag: string,
datasets: readonly DatasetQuery[],
_range: { start: Date; end: Date },
options: { includeScheduledInvocations?: boolean } = {},
) {
const rows: Record<string, DatasetRow[]> = {};
const errors: Record<string, string> = {};
for (const dataset of datasets) {
if (dataset.key === 'workers_invocations') {
errors[dataset.key] = 'boom';
} else {
rows[dataset.key] = [];
}
}
return {
rows,
errors,
scheduledInvocations: options.includeScheduledInvocations ? [] : undefined,
};
}
// eslint-disable-next-line @typescript-eslint/require-await
async fetchZoneBatch() {
return { rows: {}, errors: {} };
}
}
const collector = new CloudflareMetricsCollector(
new PartialErrorClient() as unknown as CloudflareGraphQLClient,
'acct',
metrics,
{ now: () => new Date('2026-04-10T12:15:00Z') },
);
const results = await collector.collectAll([WORKERS_INVOCATIONS, R2_OPERATIONS]);
const workersResult = results.find((r) => r.dataset === 'workers_invocations');
const r2Result = results.find((r) => r.dataset === 'r2_operations');
expect(workersResult?.error).toContain('boom');
expect(r2Result?.error).toBeUndefined();
const errorMetric = provider.metrics.find(
(m) => m.tags.get('dataset') === 'workers_invocations' && m.tags.get('status') === 'error',
);
expect(errorMetric?.tags.get('error')).toBe('graphql_field_error');
expect(errorMetric?.tags.get('error_scope')).toBe('field');
expect(errorMetric?.tags.get('error_message')).toBe('boom');
});
it('accepts date-granularity datasets and synthesizes a midnight UTC timestamp', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z' },
max: { storedBytes: 1024 },
};
const client = new FakeGraphQLClient({ durable_objects_storage: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([DURABLE_OBJECTS_STORAGE]);
const exported = provider.metrics.find((m) => m.name === 'cf_durable_objects_storage');
expect(exported?.exportTimestamp).toEqual(new Date('2026-04-10T12:00:00Z'));
expect(exported?.fields.get('stored_bytes')).toEqual({ value: 1024, type: 'int' });
});
it('emits periodic durable-object fields with correct unit scaling', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', namespaceId: 'ns' },
sum: {
activeTime: 1000,
cpuTime: 500,
duration: 72.99,
exceededCpuErrors: 0,
exceededMemoryErrors: 0,
fatalInternalErrors: 2,
inboundWebsocketMsgCount: 0,
outboundWebsocketMsgCount: 10,
rowsRead: 100,
rowsWritten: 50,
storageDeletes: 0,
storageReadUnits: 0,
storageWriteUnits: 0,
subrequests: 0,
},
max: { activeWebsocketConnections: 5 },
};
const client = new FakeGraphQLClient({ durable_objects_periodic: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([DURABLE_OBJECTS_PERIODIC]);
const exported = provider.metrics.find((m) => m.name === 'cf_durable_objects_periodic');
expect(exported?.fields.get('duration_ms')).toEqual({ value: 72_990, type: 'float' });
expect(exported?.fields.get('fatal_internal_errors')).toEqual({ value: 2, type: 'int' });
expect(exported?.fields.get('active_ws_connections_max')).toEqual({ value: 5, type: 'int' });
});
it('handles avg-only datasets like queue backlog', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', queueId: 'q1' },
avg: { bytes: 123.4, messages: 7.5, sampleInterval: 1 },
};
const client = new FakeGraphQLClient({ queue_backlog: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([QUEUE_BACKLOG]);
const exported = provider.metrics.find((m) => m.name === 'cf_queue_backlog');
expect(exported?.fields.get('backlog_bytes_avg')).toEqual({ value: 123.4, type: 'float' });
expect(exported?.fields.get('backlog_messages_avg')).toEqual({ value: 7.5, type: 'float' });
});
it('uses datetimeMinute timestamp dimension for hyperdrive pool sizes', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', configId: 'cfg', databaseType: 'pg' },
max: { currentPoolSize: 3, maxPoolSize: 10, waitingClients: 0 },
};
const client = new FakeGraphQLClient({ hyperdrive_pool: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([HYPERDRIVE_POOL]);
const exported = provider.metrics.find((m) => m.name === 'cf_hyperdrive_pool');
expect(exported?.exportTimestamp).toEqual(new Date('2026-04-10T12:00:00Z'));
expect(exported?.fields.get('current_pool_size')).toEqual({ value: 3, type: 'int' });
});
it('enriches D1 metrics with database_name from the rest client', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', databaseId: 'db-123', databaseRole: 'primary' },
sum: { readQueries: 10, writeQueries: 0, rowsRead: 100, rowsWritten: 0, queryBatchResponseBytes: 200 },
};
const client = new FakeGraphQLClient({ d1_queries: [row] });
const restClient = new FakeRestClient([{ uuid: 'db-123', name: 'releases-prod' }]);
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
await collector.collectAll([D1_QUERIES]);
const exported = provider.metrics.find((m) => m.name === 'cf_d1_queries');
expect(exported?.tags.get('database_id')).toBe('db-123');
expect(exported?.tags.get('database_name')).toBe('releases-prod');
});
it('enriches queue metrics with queue_name from the rest client', async () => {
const row: DatasetRow = {
dimensions: {
datetimeMinute: '2026-04-10T12:00:00Z',
queueId: 'q-abc',
actionType: 'WriteMessage',
consumerType: 'worker',
outcome: 'success',
},
sum: { billableOperations: 5, bytes: 1234 },
};
const client = new FakeGraphQLClient({ queue_operations: [row] });
const restClient = new FakeRestClient([], [{ queue_id: 'q-abc', queue_name: 'ingest-events' }]);
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
await collector.collectAll([QUEUE_OPERATIONS]);
const exported = provider.metrics.find((m) => m.name === 'cf_queue_operations');
expect(exported?.tags.get('queue_id')).toBe('q-abc');
expect(exported?.tags.get('queue_name')).toBe('ingest-events');
});
it('enriches queue backlog metrics with queue_name', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', queueId: 'q-abc' },
avg: { bytes: 100, messages: 2, sampleInterval: 1 },
};
const client = new FakeGraphQLClient({ queue_backlog: [row] });
const restClient = new FakeRestClient([], [{ queue_id: 'q-abc', queue_name: 'ingest-events' }]);
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
await collector.collectAll([QUEUE_BACKLOG]);
const exported = provider.metrics.find((m) => m.name === 'cf_queue_backlog');
expect(exported?.tags.get('queue_name')).toBe('ingest-events');
});
it('enriches HTTP overview metrics with zone_name', async () => {
const row: DatasetRow = {
dimensions: {
datetimeMinute: '2026-04-10T12:00:00Z',
zoneTag: 'zone-1',
clientCountryName: 'US',
clientRequestHTTPProtocol: 'HTTP/2',
edgeResponseStatus: 200,
},
sum: { requests: 100, bytes: 5000, cachedRequests: 50, cachedBytes: 2500, pageViews: 0, visits: 0 },
};
const client = new FakeGraphQLClient({ http_requests_overview: [row] });
const restClient = new FakeRestClient([], [], [{ id: 'zone-1', name: 'example.com' }]);
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
await collector.collectAll([HTTP_REQUESTS_OVERVIEW]);
const exported = provider.metrics.find((m) => m.name === 'cf_http_requests_overview');
expect(exported?.tags.get('zone_tag')).toBe('zone-1');
expect(exported?.tags.get('zone_name')).toBe('example.com');
});
it('resolves HTTP zones missing from the bulk list via per-tag lookup', async () => {
const bulkZone: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', zoneTag: 'zone-bulk' },
sum: { requests: 10, bytes: 500, cachedRequests: 0, cachedBytes: 0, pageViews: 0, visits: 0 },
};
const pagesZone: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', zoneTag: 'zone-pages' },
sum: { requests: 5, bytes: 200, cachedRequests: 0, cachedBytes: 0, pageViews: 0, visits: 0 },
};
const client = new FakeGraphQLClient({ http_requests_overview: [bulkZone, pagesZone] });
const restClient = new FakeRestClient([], [], [{ id: 'zone-bulk', name: 'bulk.example.com' }], {
'zone-pages': { id: 'zone-pages', name: 'pages.example.com' },
});
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
await collector.collectAll([HTTP_REQUESTS_OVERVIEW]);
const exported = provider.metrics.filter((m) => m.name === 'cf_http_requests_overview');
const bulk = exported.find((m) => m.tags.get('zone_tag') === 'zone-bulk');
const pages = exported.find((m) => m.tags.get('zone_tag') === 'zone-pages');
expect(bulk?.tags.get('zone_name')).toBe('bulk.example.com');
expect(pages?.tags.get('zone_name')).toBe('pages.example.com');
// Only the uncached zone should have been looked up individually.
expect(restClient.getZoneCalls).toEqual(['zone-pages']);
const lookup = provider.metrics.find(
(m) => m.name === 'cloudflare_metrics_resource_lookup' && m.tags.get('resource') === 'zones_individual',
);
expect(lookup?.tags.get('status')).toBe('success');
expect(lookup?.fields.get('resolved')).toEqual({ value: 1, type: 'int' });
expect(lookup?.fields.get('failed')).toEqual({ value: 0, type: 'int' });
});
it('falls back to the zoneTag when individual lookup returns nothing', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', zoneTag: 'zone-unknown' },
sum: { requests: 1, bytes: 1, cachedRequests: 0, cachedBytes: 0, pageViews: 0, visits: 0 },
};
const client = new FakeGraphQLClient({ http_requests_overview: [row] });
// No bulk zones and no individual lookup results — rest client returns null.
const restClient = new FakeRestClient([], [], [], { 'zone-unknown': null });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
await collector.collectAll([HTTP_REQUESTS_OVERVIEW]);
const exported = provider.metrics.find((m) => m.name === 'cf_http_requests_overview');
expect(exported?.tags.get('zone_tag')).toBe('zone-unknown');
expect(exported?.tags.get('zone_name')).toBe('zone-unknown');
});
it('omits the enrichment tag when the rest client has no match', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', databaseId: 'db-unknown', databaseRole: 'primary' },
sum: { readQueries: 1, writeQueries: 0, rowsRead: 0, rowsWritten: 0, queryBatchResponseBytes: 0 },
};
const client = new FakeGraphQLClient({ d1_queries: [row] });
const restClient = new FakeRestClient([{ uuid: 'db-123', name: 'releases-prod' }]);
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
await collector.collectAll([D1_QUERIES]);
const exported = provider.metrics.find((m) => m.name === 'cf_d1_queries');
expect(exported?.tags.get('database_id')).toBe('db-unknown');
expect(exported?.tags.has('database_name')).toBe(false);
});
it('records per-resource lookup self-metrics with success and error statuses', async () => {
const client = new FakeGraphQLClient({});
const restClient = new ThrowingRestClient();
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
await collector.collectAll([]);
const lookups = provider.metrics.filter((m) => m.name === 'cloudflare_metrics_resource_lookup');
expect(lookups).toHaveLength(3);
for (const lookup of lookups) {
expect(lookup.tags.get('status')).toBe('error');
}
const resources = new Set(lookups.map((m) => m.tags.get('resource')));
expect(resources).toEqual(new Set(['d1_databases', 'queues', 'zones']));
});
it('falls back to unenriched metrics when no rest client is provided', async () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', databaseId: 'db-123', databaseRole: 'primary' },
sum: { readQueries: 1, writeQueries: 0, rowsRead: 0, rowsWritten: 0, queryBatchResponseBytes: 0 },
};
const client = new FakeGraphQLClient({ d1_queries: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([D1_QUERIES]);
const exported = provider.metrics.find((m) => m.name === 'cf_d1_queries');
expect(exported?.tags.has('database_name')).toBe(false);
expect(provider.metrics.some((m) => m.name === 'cloudflare_metrics_resource_lookup')).toBe(false);
});
it('iterates cached zones for zone-scoped datasets and injects zone tags', async () => {
const zoneScoped: DatasetQuery = {
key: 'zone_detail',
measurement: 'cf_zone_detail',
field: 'httpRequestsAdaptiveGroups',
scope: 'zone',
dimensions: ['datetimeMinute', 'edgeResponseStatus'],
topLevelFields: ['count'],
blocks: { sum: ['edgeResponseBytes'] },
tags: [{ source: 'edgeResponseStatus', as: 'edge_response_status' }],
fields: {
requests: { type: 'int', source: ['_top', 'count'] },
edge_response_bytes: { type: 'int', source: ['sum', 'edgeResponseBytes'] },
},
};
const client = new FakeGraphQLClient(
{},
{
zone_detail: {
'zone-a': [
{
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', edgeResponseStatus: 200 },
count: 42,
sum: { edgeResponseBytes: 1024 },
},
],
'zone-b': [
{
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', edgeResponseStatus: 500 },
count: 3,
sum: { edgeResponseBytes: 0 },
},
],
},
},
);
const restClient = new FakeRestClient(
[],
[],
[
{ id: 'zone-a', name: 'a.example.com' },
{ id: 'zone-b', name: 'b.example.com' },
],
);
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
restClient,
});
const results = await collector.collectAll([zoneScoped]);
const zoneResult = results.find((r) => r.dataset === 'zone_detail');
expect(zoneResult?.rows).toBe(2);
expect(zoneResult?.points).toBe(2);
expect(client.zoneCalls).toEqual([{ zoneTags: ['zone-a', 'zone-b'], dataset: 'zone_detail' }]);
const exported = provider.metrics.filter((m) => m.name === 'cf_zone_detail');
expect(exported).toHaveLength(2);
const a = exported.find((m) => m.tags.get('zone_tag') === 'zone-a');
const b = exported.find((m) => m.tags.get('zone_tag') === 'zone-b');
expect(a?.tags.get('zone_name')).toBe('a.example.com');
expect(a?.fields.get('requests')).toEqual({ value: 42, type: 'int' });
expect(b?.tags.get('zone_name')).toBe('b.example.com');
expect(b?.fields.get('edge_response_bytes')).toEqual({ value: 0, type: 'int' });
});
it('skips zone-scoped datasets when the zone cache is empty', async () => {
const zoneScoped: DatasetQuery = {
key: 'zone_detail',
measurement: 'cf_zone_detail',
field: 'httpRequestsAdaptiveGroups',
scope: 'zone',
dimensions: ['datetimeMinute'],
topLevelFields: ['count'],
blocks: {},
tags: [],
fields: { requests: { type: 'int', source: ['_top', 'count'] } },
};
const client = new FakeGraphQLClient({});
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
const [result] = await collector.collectAll([zoneScoped]);
expect(result.rows).toBe(0);
expect(result.points).toBe(0);
const observed = provider.metrics.find(
(m) => m.name === 'cloudflare_metrics_collector_dataset' && m.tags.get('dataset') === 'zone_detail',
);
expect(observed?.tags.get('status')).toBe('skipped');
expect(observed?.tags.get('reason')).toBe('no_zones_in_cache');
});
it('aggregates scheduled worker invocations client-side into minute buckets', async () => {
const client = new FakeGraphQLClient({}, {}, [
{
scriptName: 'version-api',
cron: '*/5 * * * *',
status: 'success',
datetime: '2026-04-10T12:00:10Z',
cpuTimeUs: 1000,
},
{
scriptName: 'version-api',
cron: '*/5 * * * *',
status: 'success',
datetime: '2026-04-10T12:00:42Z',
cpuTimeUs: 3000,
},
{
scriptName: 'version-api',
cron: '*/5 * * * *',
status: 'error',
datetime: '2026-04-10T12:01:12Z',
cpuTimeUs: 500,
},
]);
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
const results = await collector.collectAll([]);
const scheduledResult = results.find((r) => r.dataset === 'workers_scheduled');
expect(scheduledResult?.rows).toBe(3);
expect(scheduledResult?.points).toBe(2); // one bucket for success in 12:00, one for error in 12:01
const scheduled = provider.metrics.filter((m) => m.name === 'cf_workers_scheduled');
expect(scheduled).toHaveLength(2);
const successBucket = scheduled.find((m) => m.tags.get('status') === 'success');
expect(successBucket?.fields.get('invocations')).toEqual({ value: 2, type: 'int' });
expect(successBucket?.fields.get('cpu_time_us_sum')).toEqual({ value: 4000, type: 'int' });
expect(successBucket?.fields.get('cpu_time_us_avg')).toEqual({ value: 2000, type: 'int' });
expect(successBucket?.fields.get('cpu_time_us_max')).toEqual({ value: 3000, type: 'int' });
expect(successBucket?.tags.get('script_name')).toBe('version-api');
expect(successBucket?.tags.get('cron')).toBe('*/5 * * * *');
expect(successBucket?.exportTimestamp).toEqual(new Date('2026-04-10T12:00:00Z'));
const errorBucket = scheduled.find((m) => m.tags.get('status') === 'error');
expect(errorBucket?.fields.get('invocations')).toEqual({ value: 1, type: 'int' });
expect(errorBucket?.exportTimestamp).toEqual(new Date('2026-04-10T12:01:00Z'));
});
it('reads top-level count fields from the row', async () => {
const dataset: DatasetQuery = {
key: 'd1_queries_detail',
measurement: 'cf_d1_queries_detail',
field: 'd1QueriesAdaptiveGroups',
dimensions: ['datetimeMinute', 'databaseId'],
topLevelFields: ['count'],
blocks: { sum: ['queryDurationMs'] },
tags: [{ source: 'databaseId', as: 'database_id' }],
fields: {
query_count: { type: 'int', source: ['_top', 'count'] },
query_duration_ms_sum: { type: 'float', source: ['sum', 'queryDurationMs'] },
},
};
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', databaseId: 'db-1' },
count: 42,
sum: { queryDurationMs: 7.5 },
};
const client = new FakeGraphQLClient({ d1_queries_detail: [row] });
const collector = new CloudflareMetricsCollector(asGraphQLClient(client), 'acct', metrics, {
now: () => new Date('2026-04-10T12:15:00Z'),
});
await collector.collectAll([dataset]);
const exported = provider.metrics.find((m) => m.name === 'cf_d1_queries_detail');
expect(exported?.fields.get('query_count')).toEqual({ value: 42, type: 'int' });
expect(exported?.fields.get('query_duration_ms_sum')).toEqual({ value: 7.5, type: 'float' });
});
});
+407
View File
@@ -0,0 +1,407 @@
import type { ICloudflareRestClient } from './cloudflare-api.js';
import { buildMetric, normalizeTagValue } from './emit.js';
import { CloudflareGraphQLError, type ICloudflareGraphQLClient } from './graphql-client.js';
import { Metric } from './metric.js';
import type { CloudflareMetricsRepository } from './metrics.js';
import { ResourceCacheService } from './resource-cache.js';
import type { CollectionResult, DatasetQuery, DatasetRow } from './types.js';
export interface CollectorOptions {
/** Cloudflare analytics data lags by a few minutes; this offsets the window end. */
lagMs?: number;
windowMs?: number;
now?: () => Date;
restClient?: ICloudflareRestClient;
}
// 5m lag avoids querying buckets Cloudflare hasn't finalised yet (pipeline
// delay is typically 2-5m). 5m window gives us ~5 consecutive ticks worth
// of overlap so a missed minute is still covered by neighbouring ticks.
// Cloudflare drops/delays ~25% of our cron triggers in practice; a wider
// window makes multi-minute gaps less likely to turn into data loss.
// VictoriaMetrics dedupes the overlap so there's no storage cost.
const DEFAULT_LAG_MS = 5 * 60 * 1000;
const DEFAULT_WINDOW_MS = 5 * 60 * 1000;
export class CloudflareMetricsCollector {
private readonly lagMs: number;
private readonly windowMs: number;
private readonly now: () => Date;
private readonly resourceCache: ResourceCacheService;
constructor(
private readonly client: ICloudflareGraphQLClient,
private readonly accountTag: string,
private readonly metrics: CloudflareMetricsRepository,
options: CollectorOptions = {},
) {
this.lagMs = options.lagMs ?? DEFAULT_LAG_MS;
this.windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
this.now = options.now ?? (() => new Date());
this.resourceCache = new ResourceCacheService(this.accountTag, this.metrics, this.now, options.restClient);
}
getRange(): { start: Date; end: Date } {
const end = new Date(this.now().getTime() - this.lagMs);
const start = new Date(end.getTime() - this.windowMs);
return { start, end };
}
async collectAll(datasets: readonly DatasetQuery[]): Promise<CollectionResult[]> {
await this.resourceCache.populate();
const range = this.getRange();
const results: CollectionResult[] = [];
// Single-pass classification — avoids two .filter() allocations over the
// ~79-dataset registry on every tick.
const accountDatasets: DatasetQuery[] = [];
const zoneDatasets: DatasetQuery[] = [];
for (const d of datasets) {
if (d.scope === 'zone') {
zoneDatasets.push(d);
} else {
accountDatasets.push(d);
}
}
results.push(...(await this.collectAccountBatch(accountDatasets, range, { includeScheduledInvocations: true })));
for (const dataset of zoneDatasets) {
results.push(await this.collectZoneBatch(dataset, range));
}
return results;
}
private async collectAccountBatch(
datasets: readonly DatasetQuery[],
range: { start: Date; end: Date },
options: { includeScheduledInvocations: boolean },
): Promise<CollectionResult[]> {
if (datasets.length === 0 && !options.includeScheduledInvocations) {
return [];
}
const results: CollectionResult[] = [];
const startedAt = performance.now();
let batchResult: Awaited<ReturnType<ICloudflareGraphQLClient['fetchAccountBatch']>>;
try {
batchResult = await this.client.fetchAccountBatch(this.accountTag, datasets, range, options);
} catch (error) {
const durationMs = performance.now() - startedAt;
const message = errorMessage(error);
console.error('[collector] account batch fetch failed:', message);
for (const dataset of datasets) {
this.pushDatasetErrorMetric(dataset.key, error, durationMs, 'batch');
results.push({ dataset: dataset.key, rows: 0, points: 0, durationMs, error: message });
}
if (options.includeScheduledInvocations) {
this.pushDatasetErrorMetric('workers_scheduled', error, durationMs, 'batch');
results.push({ dataset: 'workers_scheduled', rows: 0, points: 0, durationMs, error: message });
}
return results;
}
for (const dataset of datasets) {
const perStart = performance.now();
if (batchResult.errors[dataset.key]) {
const msg = batchResult.errors[dataset.key];
console.error(`[collector] ${dataset.key} field error:`, msg);
this.pushDatasetErrorMetric(dataset.key, new Error(msg), performance.now() - perStart);
results.push({
dataset: dataset.key,
rows: 0,
points: 0,
durationMs: performance.now() - perStart,
error: msg,
});
continue;
}
const rows = batchResult.rows[dataset.key] ?? [];
if (dataset.field === 'httpRequestsOverviewAdaptiveGroups') {
// Pages projects have zone tags in analytics but aren't in the
// bulk /zones list; resolve them individually for human-readable names.
await this.resolveZonesForRows(rows);
}
const points = this.emitRows(dataset, rows);
const durationMs = performance.now() - perStart;
this.metrics.push(
Metric.create('collector_dataset')
.addTag('dataset', dataset.key)
.addTag('status', 'success')
.intField('rows', rows.length)
.intField('points', points)
.durationField('duration', durationMs),
);
results.push({ dataset: dataset.key, rows: rows.length, points, durationMs });
}
if (options.includeScheduledInvocations) {
results.push(this.emitScheduledInvocations(batchResult));
}
return results;
}
private emitScheduledInvocations(
batchResult: Awaited<ReturnType<ICloudflareGraphQLClient['fetchAccountBatch']>>,
): CollectionResult {
const key = 'workers_scheduled';
const startedAt = performance.now();
if (batchResult.errors[key]) {
const msg = batchResult.errors[key];
console.error(`[collector] ${key} field error:`, msg);
this.pushDatasetErrorMetric(key, new Error(msg), performance.now() - startedAt);
return { dataset: key, rows: 0, points: 0, durationMs: performance.now() - startedAt, error: msg };
}
const events = batchResult.scheduledInvocations ?? [];
type Bucket = {
scriptName: string;
cron: string;
status: string;
minute: string;
count: number;
cpuTimeUs: number;
cpuTimeMax: number;
};
const buckets = new Map<string, Bucket>();
for (const event of events) {
if (!event.datetime) {
continue;
}
const minute = event.datetime.slice(0, 16) + ':00Z';
const bucketKey = `${event.scriptName}|${event.cron}|${event.status}|${minute}`;
let bucket = buckets.get(bucketKey);
if (!bucket) {
bucket = {
scriptName: event.scriptName,
cron: event.cron,
status: event.status,
minute,
count: 0,
cpuTimeUs: 0,
cpuTimeMax: 0,
};
buckets.set(bucketKey, bucket);
}
bucket.count++;
bucket.cpuTimeUs += event.cpuTimeUs ?? 0;
bucket.cpuTimeMax = Math.max(bucket.cpuTimeMax, event.cpuTimeUs ?? 0);
}
let points = 0;
for (const bucket of buckets.values()) {
const timestamp = new Date(bucket.minute);
if (Number.isNaN(timestamp.getTime())) {
continue;
}
const metric = Metric.create('cf_workers_scheduled');
metric.addTag('account_id', this.accountTag);
if (bucket.scriptName) {
metric.addTag('script_name', bucket.scriptName);
}
if (bucket.cron) {
metric.addTag('cron', bucket.cron);
}
if (bucket.status) {
metric.addTag('status', bucket.status);
}
metric
.intField('invocations', bucket.count)
.intField('cpu_time_us_sum', Math.round(bucket.cpuTimeUs))
.intField('cpu_time_us_avg', bucket.count > 0 ? Math.round(bucket.cpuTimeUs / bucket.count) : 0)
.intField('cpu_time_us_max', Math.round(bucket.cpuTimeMax))
.setExportTimestamp(timestamp);
this.metrics.pushRaw(metric);
points++;
}
const durationMs = performance.now() - startedAt;
this.metrics.push(
Metric.create('collector_dataset')
.addTag('dataset', key)
.addTag('status', 'success')
.intField('rows', events.length)
.intField('points', points)
.durationField('duration', durationMs),
);
return { dataset: key, rows: events.length, points, durationMs };
}
private async collectZoneBatch(dataset: DatasetQuery, range: { start: Date; end: Date }): Promise<CollectionResult> {
const startedAt = performance.now();
// Only bulk-listed zones (real account zones), not Pages project zones.
// Iterate the bulk tag set directly and do a single Map lookup per tag —
// avoids spreading the full zones map and an extra filter pass.
const cache = this.resourceCache.getCache();
const zoneTags: string[] = [];
const zoneNames: string[] = [];
for (const tag of cache.bulkZoneTags) {
const name = cache.zones.get(tag);
if (name !== undefined) {
zoneTags.push(tag);
zoneNames.push(name);
}
}
if (zoneTags.length === 0) {
this.metrics.push(
Metric.create('collector_dataset')
.addTag('dataset', dataset.key)
.addTag('status', 'skipped')
.addTag('reason', 'no_zones_in_cache')
.intField('rows', 0)
.intField('points', 0)
.durationField('duration', 0),
);
return { dataset: dataset.key, rows: 0, points: 0, durationMs: 0 };
}
let batchResult: Awaited<ReturnType<ICloudflareGraphQLClient['fetchZoneBatch']>>;
try {
batchResult = await this.client.fetchZoneBatch(zoneTags, dataset, range);
} catch (error) {
const durationMs = performance.now() - startedAt;
const message = errorMessage(error);
console.error(`[collector] ${dataset.key} zone batch fetch failed:`, message);
this.pushDatasetErrorMetric(dataset.key, error, durationMs, 'zone');
return { dataset: dataset.key, rows: 0, points: 0, durationMs, error: message };
}
let totalRows = 0;
let totalPoints = 0;
let zoneErrors = 0;
for (let i = 0; i < zoneTags.length; i++) {
const zoneTag = zoneTags[i];
const zoneName = zoneNames[i];
if (batchResult.errors[zoneTag]) {
zoneErrors++;
console.error(`[collector] ${dataset.key} zone ${zoneTag} error:`, batchResult.errors[zoneTag]);
continue;
}
const rows = batchResult.rows[zoneTag] ?? [];
totalRows += rows.length;
for (const row of rows) {
// Mutate in-place — we own the row (just parsed from JSON) and
// avoiding the spread is a meaningful saving in the hot path.
if (row.dimensions) {
row.dimensions.zoneTag = zoneTag;
} else {
row.dimensions = { zoneTag };
}
const metric = buildMetric(dataset, row, this.accountTag, cache);
if (metric) {
metric.addTag('zone_tag', zoneTag);
metric.addTag('zone_name', zoneName);
this.metrics.pushRaw(metric);
totalPoints++;
}
}
}
const durationMs = performance.now() - startedAt;
const status = zoneErrors === 0 ? 'success' : zoneErrors === zoneTags.length ? 'error' : 'partial';
this.metrics.push(
Metric.create('collector_dataset')
.addTag('dataset', dataset.key)
.addTag('status', status)
.intField('rows', totalRows)
.intField('points', totalPoints)
.intField('zones', zoneTags.length)
.intField('zone_errors', zoneErrors)
.durationField('duration', durationMs),
);
return {
dataset: dataset.key,
rows: totalRows,
points: totalPoints,
durationMs,
error: zoneErrors === zoneTags.length ? 'all zones errored' : undefined,
};
}
private emitRows(dataset: DatasetQuery, rows: DatasetRow[]): number {
const cache = this.resourceCache.getCache();
let emitted = 0;
for (const row of rows) {
const metric = buildMetric(dataset, row, this.accountTag, cache);
if (metric) {
this.metrics.pushRaw(metric);
emitted++;
}
}
return emitted;
}
private async resolveZonesForRows(rows: DatasetRow[]): Promise<void> {
const missing = new Set<string>();
for (const row of rows) {
const tag = normalizeTagValue(row.dimensions?.zoneTag);
if (tag) {
missing.add(tag);
}
}
await this.resourceCache.resolveMissingZones(missing);
}
private pushDatasetErrorMetric(
datasetKey: string,
error: unknown,
durationMs: number,
scope: 'batch' | 'field' | 'zone' = 'field',
): void {
this.metrics.push(
Metric.create('collector_dataset')
.addTag('dataset', datasetKey)
.addTag('status', 'error')
.addTag('error', errorTag(error))
.addTag('error_scope', scope)
.addTag('error_message', truncateTag(errorMessage(error), 128))
.intField('errors', 1)
.durationField('duration', durationMs),
);
}
}
function errorMessage(error: unknown): string {
if (error instanceof CloudflareGraphQLError) {
// Include the response body snippet when available — it often contains
// the real reason (rate limit, auth, etc).
const body = error.responseBody?.slice(0, 200) ?? '';
return body ? `${error.message}: ${body}` : error.message;
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function errorTag(error: unknown): string {
if (error instanceof CloudflareGraphQLError) {
return `graphql_${error.statusCode}`;
}
if (error instanceof Error) {
// Classify common Cloudflare GraphQL error messages into stable tags.
const msg = error.message.toLowerCase();
if (msg.includes('rate limit') || msg.includes('throttl')) {
return 'rate_limited';
}
if (msg.includes('timeout') || msg.includes('timed out')) {
return 'timeout';
}
if (msg.includes('unauthorized') || msg.includes('forbidden') || msg.includes('authentication')) {
return 'auth_error';
}
if (msg.includes('not found') || msg.includes('does not exist')) {
return 'not_found';
}
if (msg.includes('internal server error') || msg.includes('internal error')) {
return 'internal_error';
}
return error.name === 'Error' ? 'graphql_field_error' : error.name;
}
return 'unknown';
}
/** Truncate a string for use as a metric tag value. */
function truncateTag(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return value.slice(0, maxLength - 3) + '...';
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { ALL_DATASETS } from './datasets.js';
describe('dataset registry invariants', () => {
it('every dataset defines at least one tag or field', () => {
for (const dataset of ALL_DATASETS) {
const fieldCount = Object.keys(dataset.fields).length;
expect(fieldCount, `${dataset.key} must have fields`).toBeGreaterThan(0);
for (const [, spec] of Object.entries(dataset.fields)) {
expect(['int', 'float']).toContain(spec.type);
expect(spec.source[0]).toMatch(/^(sum|avg|max|min|quantiles|uniq|_top)$/);
}
}
});
it('every dataset selects its timestamp dimension', () => {
for (const dataset of ALL_DATASETS) {
const timestampDim = dataset.timestampDimension ?? 'datetimeMinute';
expect(dataset.dimensions, `${dataset.key} must include ${timestampDim}`).toContain(timestampDim);
}
});
it('measurement names are unique and cf_-prefixed', () => {
const seen = new Set<string>();
for (const dataset of ALL_DATASETS) {
expect(dataset.measurement).toMatch(/^cf_/);
expect(seen.has(dataset.measurement)).toBe(false);
seen.add(dataset.measurement);
}
});
});
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
export class DeferredRepository {
private deferred: (() => Promise<unknown>)[] = [];
constructor(private ctx: ExecutionContext) {}
defer(call: () => Promise<unknown>): void {
this.deferred.push(call);
}
runDeferred() {
for (const call of this.deferred) {
this.ctx.waitUntil(call());
}
}
}
+255
View File
@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { buildMetric, normalizeTagValue, resolveTimestamp } from './emit.js';
import { __resetResourceCachesForTests, type ResourceCache } from './resource-cache.js';
import type { DatasetQuery, DatasetRow } from './types.js';
function emptyCache(): ResourceCache {
return {
d1Databases: new Map(),
queues: new Map(),
zones: new Map(),
bulkZoneTags: new Set(),
};
}
const WORKERS_DATASET: DatasetQuery = {
key: 'workers_invocations',
measurement: 'cf_workers_invocations',
field: 'workersInvocationsAdaptive',
dimensions: ['datetimeMinute', 'scriptName', 'status'],
topLevelFields: [],
blocks: { sum: ['requests', 'errors'] },
tags: [
{ source: 'scriptName', as: 'script_name' },
{ source: 'status', as: 'status' },
],
fields: {
requests: { type: 'int', source: ['sum', 'requests'] },
errors: { type: 'int', source: ['sum', 'errors'] },
},
};
describe('resolveTimestamp', () => {
it('parses datetimeMinute ISO string', () => {
const row: DatasetRow = { dimensions: { datetimeMinute: '2026-04-10T12:00:00Z' } };
const result = resolveTimestamp(WORKERS_DATASET, row);
expect(result).toEqual(new Date('2026-04-10T12:00:00Z'));
});
it('returns null when dimension is missing', () => {
const row: DatasetRow = { dimensions: {} };
expect(resolveTimestamp(WORKERS_DATASET, row)).toBeNull();
});
it('returns null when dimension value is null', () => {
const row: DatasetRow = { dimensions: { datetimeMinute: null } };
expect(resolveTimestamp(WORKERS_DATASET, row)).toBeNull();
});
it('returns null for invalid date strings', () => {
const row: DatasetRow = { dimensions: { datetimeMinute: 'not-a-date' } };
expect(resolveTimestamp(WORKERS_DATASET, row)).toBeNull();
});
it('converts date dimension to UTC midnight', () => {
const dataset: DatasetQuery = {
...WORKERS_DATASET,
timestampDimension: 'date',
};
const row: DatasetRow = { dimensions: { date: '2026-04-10' } };
const result = resolveTimestamp(dataset, row);
expect(result).toEqual(new Date('2026-04-10T00:00:00Z'));
});
it('coerces non-string values to string', () => {
const row: DatasetRow = { dimensions: { datetimeMinute: 1_744_272_000_000 as unknown as string } };
expect(resolveTimestamp(WORKERS_DATASET, row)).toBeNull();
});
});
describe('normalizeTagValue', () => {
it('returns string values as-is', () => {
expect(normalizeTagValue('hello')).toBe('hello');
});
it('returns undefined for empty strings', () => {
expect(normalizeTagValue('')).toBeUndefined();
});
it('returns undefined for null', () => {
expect(normalizeTagValue(null)).toBeUndefined();
});
it('returns undefined for undefined', () => {
expect(normalizeTagValue()).toBeUndefined();
});
it('coerces numbers to strings', () => {
expect(normalizeTagValue(200)).toBe('200');
expect(normalizeTagValue(0)).toBe('0');
});
it('coerces booleans to strings', () => {
expect(normalizeTagValue(true)).toBe('true');
expect(normalizeTagValue(false)).toBe('false');
});
it('returns undefined for objects', () => {
expect(normalizeTagValue({})).toBeUndefined();
expect(normalizeTagValue([])).toBeUndefined();
});
});
describe('buildMetric', () => {
beforeEach(() => {
__resetResourceCachesForTests();
});
it('builds a metric with tags and fields from a row', () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', scriptName: 'my-worker', status: 'success' },
sum: { requests: 10, errors: 0 },
};
const metric = buildMetric(WORKERS_DATASET, row, 'acct-1', emptyCache());
expect(metric).not.toBeNull();
expect(metric!.name).toBe('cf_workers_invocations');
expect(metric!.tags.get('account_id')).toBe('acct-1');
expect(metric!.tags.get('script_name')).toBe('my-worker');
expect(metric!.tags.get('status')).toBe('success');
expect(metric!.fields.get('requests')).toEqual({ value: 10, type: 'int' });
expect(metric!.fields.get('errors')).toEqual({ value: 0, type: 'int' });
expect(metric!.exportTimestamp).toEqual(new Date('2026-04-10T12:00:00Z'));
});
it('returns null when timestamp is missing', () => {
const row: DatasetRow = { dimensions: {}, sum: { requests: 1 } };
expect(buildMetric(WORKERS_DATASET, row, 'acct', emptyCache())).toBeNull();
});
it('returns null when no numeric fields are present', () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z' },
sum: { requests: null, errors: null },
};
expect(buildMetric(WORKERS_DATASET, row, 'acct', emptyCache())).toBeNull();
});
it('skips null fields but keeps zero-valued fields', () => {
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', scriptName: 'w', status: 'ok' },
sum: { requests: 0, errors: null },
};
const metric = buildMetric(WORKERS_DATASET, row, 'acct', emptyCache());
expect(metric).not.toBeNull();
expect(metric!.fields.has('requests')).toBe(true);
expect(metric!.fields.has('errors')).toBe(false);
});
it('applies float type and scale factor', () => {
const dataset: DatasetQuery = {
...WORKERS_DATASET,
fields: {
duration_ms: { type: 'float', source: ['sum', 'duration'], scale: 1000 },
},
};
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z' },
sum: { duration: 0.5 },
};
const metric = buildMetric(dataset, row, 'acct', emptyCache());
expect(metric!.fields.get('duration_ms')).toEqual({ value: 500, type: 'float' });
});
it('reads top-level fields via _top source', () => {
const dataset: DatasetQuery = {
...WORKERS_DATASET,
topLevelFields: ['count'],
fields: {
total: { type: 'int', source: ['_top', 'count'] },
},
};
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z' },
count: 42,
};
const metric = buildMetric(dataset, row, 'acct', emptyCache());
expect(metric!.fields.get('total')).toEqual({ value: 42, type: 'int' });
});
it('enriches D1 rows with database_name from cache', () => {
const dataset: DatasetQuery = {
key: 'd1_queries',
measurement: 'cf_d1_queries',
field: 'd1QueriesAdaptiveGroups',
dimensions: ['datetimeMinute', 'databaseId'],
topLevelFields: [],
blocks: { sum: ['readQueries'] },
tags: [{ source: 'databaseId', as: 'database_id' }],
fields: { read_queries: { type: 'int', source: ['sum', 'readQueries'] } },
};
const cache = emptyCache();
cache.d1Databases.set('db-1', 'my-database');
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', databaseId: 'db-1' },
sum: { readQueries: 5 },
};
const metric = buildMetric(dataset, row, 'acct', cache);
expect(metric!.tags.get('database_name')).toBe('my-database');
});
it('enriches queue rows with queue_name from cache', () => {
const dataset: DatasetQuery = {
key: 'queue_operations',
measurement: 'cf_queue_operations',
field: 'queueMessageOperationsAdaptiveGroups',
dimensions: ['datetimeMinute', 'queueId'],
topLevelFields: [],
blocks: { sum: ['billableOperations'] },
tags: [{ source: 'queueId', as: 'queue_id' }],
fields: { billable_operations: { type: 'int', source: ['sum', 'billableOperations'] } },
};
const cache = emptyCache();
cache.queues.set('q-1', 'my-queue');
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', queueId: 'q-1' },
sum: { billableOperations: 10 },
};
const metric = buildMetric(dataset, row, 'acct', cache);
expect(metric!.tags.get('queue_name')).toBe('my-queue');
});
it('enriches HTTP rows with zone_name, falling back to zone tag', () => {
const dataset: DatasetQuery = {
key: 'http_requests_overview',
measurement: 'cf_http_requests_overview',
field: 'httpRequestsOverviewAdaptiveGroups',
dimensions: ['datetimeMinute', 'zoneTag'],
topLevelFields: [],
blocks: { sum: ['requests'] },
tags: [{ source: 'zoneTag', as: 'zone_tag' }],
fields: { requests: { type: 'int', source: ['sum', 'requests'] } },
};
const cache = emptyCache();
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', zoneTag: 'zone-xyz' },
sum: { requests: 1 },
};
const metric = buildMetric(dataset, row, 'acct', cache);
expect(metric!.tags.get('zone_name')).toBe('zone-xyz');
});
it('does not apply enrichment for unrecognized dataset fields', () => {
const dataset: DatasetQuery = {
...WORKERS_DATASET,
field: 'somethingElse',
};
const row: DatasetRow = {
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', scriptName: 'w', status: 's' },
sum: { requests: 1, errors: 0 },
};
const metric = buildMetric(dataset, row, 'acct', emptyCache());
expect(metric!.tags.has('database_name')).toBe(false);
expect(metric!.tags.has('queue_name')).toBe(false);
expect(metric!.tags.has('zone_name')).toBe(false);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { Metric } from './metric.js';
import type { ResourceCache } from './resource-cache.js';
import type { DatasetQuery, DatasetRow, FieldSpec } from './types.js';
// Precomputed per-dataset field entries to avoid Object.entries() per row.
// Keyed by dataset identity — safe because dataset objects are module-level
// constants that live for the lifetime of the isolate.
type FieldEntry = readonly [string, string, string, FieldSpec];
const fieldEntriesCache = new WeakMap<DatasetQuery, FieldEntry[]>();
function getFieldEntries(dataset: DatasetQuery): FieldEntry[] {
let cached = fieldEntriesCache.get(dataset);
if (!cached) {
cached = Object.entries(dataset.fields).map(
([name, spec]) => [name, spec.source[0], spec.source[1], spec] as const,
);
fieldEntriesCache.set(dataset, cached);
}
return cached;
}
// Precomputed per-dataset resource tag enricher. The original code ran a
// `switch(dataset.field)` on every row — bypass that per-row work by
// resolving the correct enricher once per dataset and caching the closure.
type TagEnricher = (metric: Metric, row: DatasetRow, cache: ResourceCache) => void;
const noopEnricher: TagEnricher = () => {};
const tagEnricherCache = new WeakMap<DatasetQuery, TagEnricher>();
function getTagEnricher(dataset: DatasetQuery): TagEnricher {
let cached = tagEnricherCache.get(dataset);
if (cached) {
return cached;
}
switch (dataset.field) {
case 'd1AnalyticsAdaptiveGroups':
case 'd1StorageAdaptiveGroups':
case 'd1QueriesAdaptiveGroups': {
cached = (metric, row, cache) => {
const id = row.dimensions?.databaseId;
if (typeof id === 'string' && id !== '') {
const name = cache.d1Databases.get(id);
if (name) {
metric.addTag('database_name', name);
}
}
};
break;
}
case 'queueMessageOperationsAdaptiveGroups':
case 'queueBacklogAdaptiveGroups': {
cached = (metric, row, cache) => {
const id = row.dimensions?.queueId;
if (typeof id === 'string' && id !== '') {
const name = cache.queues.get(id);
if (name) {
metric.addTag('queue_name', name);
}
}
};
break;
}
case 'httpRequestsOverviewAdaptiveGroups': {
cached = (metric, row, cache) => {
const tag = row.dimensions?.zoneTag;
if (typeof tag === 'string' && tag !== '') {
metric.addTag('zone_name', cache.zones.get(tag) ?? tag);
}
};
break;
}
default: {
cached = noopEnricher;
}
}
tagEnricherCache.set(dataset, cached);
return cached;
}
export function buildMetric(
dataset: DatasetQuery,
row: DatasetRow,
accountTag: string,
resourceCache: ResourceCache,
): Metric | null {
const timestamp = resolveTimestamp(dataset, row);
if (!timestamp) {
return null;
}
const metric = Metric.create(dataset.measurement);
metric.addTag('account_id', accountTag);
metric.setExportTimestamp(timestamp);
const dims = row.dimensions;
for (const tag of dataset.tags) {
const raw = dims?.[tag.source];
const value = normalizeTagValue(raw);
if (value !== undefined) {
metric.addTag(tag.as, value);
}
}
getTagEnricher(dataset)(metric, row, resourceCache);
const rowAny = row as unknown as Record<string, unknown>;
let hasField = false;
for (const entry of getFieldEntries(dataset)) {
const [fieldName, block, key, spec] = entry;
let raw: number | null | undefined;
if (block === '_top') {
raw = rowAny[key] as number | null | undefined;
} else {
const blockData = rowAny[block] as Record<string, number | null> | undefined;
raw = blockData?.[key];
}
if (raw === null || raw === undefined) {
continue;
}
const value = spec.scale ? raw * spec.scale : raw;
if (spec.type === 'float') {
metric.floatField(fieldName, value);
} else {
metric.intField(fieldName, Math.round(value));
}
hasField = true;
}
if (!hasField) {
return null;
}
return metric;
}
export function resolveTimestamp(dataset: DatasetQuery, row: DatasetRow): Date | null {
const dimension = dataset.timestampDimension ?? 'datetimeMinute';
const raw = row.dimensions?.[dimension];
if (raw === null || raw === undefined) {
return null;
}
const str = String(raw);
// `date` dimension is "YYYY-MM-DD" — treat as UTC midnight.
if (dimension === 'date') {
return new Date(`${str}T00:00:00Z`);
}
const date = new Date(str);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
}
export function normalizeTagValue(raw?: unknown): string | undefined {
if (raw === null || raw === undefined) {
return undefined;
}
if (typeof raw === 'string') {
return raw === '' ? undefined : raw;
}
if (typeof raw === 'number' || typeof raw === 'boolean') {
return String(raw);
}
return undefined;
}
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
__resetFlushStateForTests,
MAX_PENDING_FLUSH_BYTES,
pendingFlushBuffers,
recordLastFlushStats,
takeLastFlushStats,
} from './flush-state.js';
describe('flush-state', () => {
beforeEach(() => {
__resetFlushStateForTests();
});
describe('takeLastFlushStats', () => {
it('returns null when no stats have been recorded', () => {
expect(takeLastFlushStats()).toBeNull();
});
it('returns the recorded stats and clears them', () => {
const stats = {
bytes: 1024,
durationMs: 50,
status: 'ok' as const,
pendingBuffers: 0,
pendingBytes: 0,
};
recordLastFlushStats(stats);
expect(takeLastFlushStats()).toEqual(stats);
expect(takeLastFlushStats()).toBeNull();
});
it('returns the most recent stats when recorded multiple times', () => {
recordLastFlushStats({ bytes: 100, durationMs: 10, status: 'ok', pendingBuffers: 0, pendingBytes: 0 });
recordLastFlushStats({ bytes: 200, durationMs: 20, status: 'error', pendingBuffers: 1, pendingBytes: 100 });
const stats = takeLastFlushStats();
expect(stats?.bytes).toBe(200);
expect(stats?.status).toBe('error');
});
});
describe('pendingFlushBuffers', () => {
it('starts empty', () => {
expect(pendingFlushBuffers).toHaveLength(0);
});
it('is a mutable array', () => {
pendingFlushBuffers.push('body1', 'body2');
expect(pendingFlushBuffers).toHaveLength(2);
expect(pendingFlushBuffers[0]).toBe('body1');
});
it('is cleared by reset', () => {
pendingFlushBuffers.push('body');
__resetFlushStateForTests();
expect(pendingFlushBuffers).toHaveLength(0);
});
});
describe('MAX_PENDING_FLUSH_BYTES', () => {
it('is 10 MB', () => {
expect(MAX_PENDING_FLUSH_BYTES).toBe(10 * 1024 * 1024);
});
});
});
@@ -0,0 +1,28 @@
export const pendingFlushBuffers: string[] = [];
// Cap retry buffer at 10 MB; eviction drops oldest-first.
export const MAX_PENDING_FLUSH_BYTES = 10 * 1024 * 1024;
export interface LastFlushStats {
bytes: number;
durationMs: number;
status: 'ok' | 'error';
pendingBuffers: number;
pendingBytes: number;
}
let lastFlushStats: LastFlushStats | null = null;
export function takeLastFlushStats(): LastFlushStats | null {
const stats = lastFlushStats;
lastFlushStats = null;
return stats;
}
export function recordLastFlushStats(stats: LastFlushStats): void {
lastFlushStats = stats;
}
export function __resetFlushStateForTests(): void {
pendingFlushBuffers.length = 0;
lastFlushStats = null;
}
@@ -0,0 +1,139 @@
import type { DatasetQuery, GraphQLResponse } from './types.js';
export const DEFAULT_DATASET_LIMIT = 9999;
// Cloudflare GraphQL rejects batched queries above ~50 aliased datasets;
// 25 per chunk leaves headroom for datasets with extra blocks/dimensions.
export const ACCOUNT_BATCH_CHUNK_SIZE = 25;
export function chunkArray<T>(items: readonly T[], size: number): T[][] {
if (size <= 0) {
return items.length > 0 ? [[...items]] : [];
}
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size) as T[]);
}
return chunks;
}
export function buildDatasetSelection(dataset: DatasetQuery, alias?: string): string {
const dimensionSelection = dataset.dimensions.join(' ');
const blockSelections = (Object.entries(dataset.blocks) as Array<[string, readonly string[] | undefined]>)
.filter(([, fields]) => fields && fields.length > 0)
.map(([block, fields]) => `${block} { ${(fields ?? []).join(' ')} }`)
.join(' ');
const topLevelSelection = (dataset.topLevelFields ?? []).join(' ');
const limit = dataset.limit ?? DEFAULT_DATASET_LIMIT;
const aliasPrefix = alias && alias !== dataset.field ? `${alias}: ` : '';
return `${aliasPrefix}${dataset.field}(limit: ${limit}, filter: $filter${orderByClause(dataset)}) {
dimensions { ${dimensionSelection} }
${topLevelSelection}
${blockSelections}
}`;
}
const SCHEDULED_INVOCATIONS_SELECTION = `workers_scheduled: workersInvocationsScheduled(limit: ${DEFAULT_DATASET_LIMIT}, filter: $filter) {
scriptName
cron
status
datetime
cpuTimeUs
}`;
export function buildBatchedAccountQuery(
datasets: readonly DatasetQuery[],
includeScheduledInvocations = false,
): string {
const selections = datasets.map((d) => buildDatasetSelection(d, d.key));
if (includeScheduledInvocations) {
selections.push(SCHEDULED_INVOCATIONS_SELECTION);
}
return `query CloudflareMetricsAccountBatch($accountTag: String!, $filter: JSON!) {
viewer {
accounts(filter: { accountTag: $accountTag }) {
${selections.join('\n ')}
}
}
}`;
}
export function buildBatchedZoneQuery(zoneTags: readonly string[], dataset: DatasetQuery): string {
const safeZoneTags = zoneTags.map((tag) => {
if (!/^[a-zA-Z0-9_-]+$/.test(tag)) {
throw new Error(`Invalid zoneTag for batched query: ${tag}`);
}
return tag;
});
const selections = safeZoneTags
.map(
(tag, index) => ` z${index}: zones(filter: { zoneTag: "${tag}" }) {
${buildDatasetSelection(dataset, dataset.field)}
}`,
)
.join('\n');
return `query CloudflareMetricsZoneBatch($filter: JSON!) {
viewer {
${selections}
}
}`;
}
export function buildFilterObject(
dataset: DatasetQuery | null,
range: { start: Date; end: Date },
): Record<string, unknown> {
const filter: Record<string, unknown> = dataset?.extraFilter ? { ...dataset.extraFilter } : {};
filter.datetime_geq = range.start.toISOString();
filter.datetime_leq = range.end.toISOString();
return filter;
}
export function groupErrorsByAlias(
errors: GraphQLResponse<unknown>['errors'] | null | undefined,
): Record<string, string> {
const result: Record<string, string> = {};
if (!errors) {
return result;
}
for (const error of errors) {
const alias = findAliasInPath(error.path);
if (alias) {
const existing = result[alias];
result[alias] = existing ? `${existing}; ${error.message}` : error.message;
}
}
return result;
}
function findAliasInPath(path: readonly (string | number)[] | undefined): string | undefined {
if (!path) {
return undefined;
}
// Typical path shape:
// ['viewer', 'accounts', 0, '<alias>']
// ['viewer', '<alias>', 0, '<innerField>'] (zone batches)
// Return the first path segment after a list index that isn't part of
// the `dimensions` / `sum` / etc. internals.
for (let i = 0; i < path.length; i++) {
const segment = path[i];
if (typeof segment !== 'string') {
continue;
}
if (segment === 'viewer' || segment === 'accounts' || segment === 'zones') {
continue;
}
return segment;
}
return undefined;
}
function orderByClause(dataset: DatasetQuery): string {
const timestampDim = dataset.timestampDimension ?? 'datetimeMinute';
// Grouping implicitly takes place on the dimensions, so orderBy helps
// make sure we get the most recent buckets within the limit.
if (dataset.dimensions.includes(timestampDim)) {
return `, orderBy: [${timestampDim}_ASC]`;
}
return '';
}
@@ -0,0 +1,423 @@
import { describe, expect, it, vi } from 'vitest';
import { ALL_DATASETS, D1_QUERIES, WORKERS_INVOCATIONS } from './datasets.js';
import {
buildBatchedAccountQuery,
buildBatchedZoneQuery,
buildDatasetSelection,
buildFilterObject,
groupErrorsByAlias,
} from './graphql-builders.js';
import { CloudflareGraphQLClient, CloudflareGraphQLError } from './graphql-client.js';
import type { DatasetQuery } from './types.js';
describe('buildDatasetSelection', () => {
it('produces an aliased field selection with dimensions and blocks', () => {
const selection = buildDatasetSelection(WORKERS_INVOCATIONS, 'workers_invocations');
expect(selection).toContain('workers_invocations: workersInvocationsAdaptive(limit: 9999, filter: $filter');
expect(selection).toContain('dimensions { datetimeMinute scriptName status scriptVersion usageModel }');
expect(selection).toContain('sum { requests errors');
expect(selection).toContain('quantiles { cpuTimeP50 cpuTimeP99');
expect(selection).toContain('orderBy: [datetimeMinute_ASC]');
});
it('omits the alias prefix when the alias matches the field name', () => {
const selection = buildDatasetSelection(WORKERS_INVOCATIONS);
expect(selection.startsWith('workersInvocationsAdaptive(')).toBe(true);
// The GraphQL `filter: $filter` argument also uses `: ` so we can't
// test absence of the colon globally — just assert the alias prefix
// isn't present at the start.
expect(selection).not.toMatch(/^workers_invocations: workersInvocationsAdaptive/);
});
it('includes top-level scalar fields between dimensions and blocks', () => {
const dataset: DatasetQuery = {
key: 'x',
measurement: 'cf_x',
field: 'xGroups',
dimensions: ['datetimeMinute'],
topLevelFields: ['count'],
blocks: {},
tags: [],
fields: { count: { type: 'int', source: ['_top', 'count'] } },
};
const selection = buildDatasetSelection(dataset, 'x');
expect(selection).toMatch(/dimensions \{ datetimeMinute \}\s+count/);
});
it('omits orderBy when the timestamp dimension is not selected', () => {
const dataset: DatasetQuery = { ...WORKERS_INVOCATIONS, dimensions: ['scriptName'] };
const selection = buildDatasetSelection(dataset, 'x');
expect(selection).not.toContain('orderBy');
});
});
describe('buildBatchedAccountQuery', () => {
it('wraps multiple aliased selections in a single viewer.accounts query', () => {
const query = buildBatchedAccountQuery([WORKERS_INVOCATIONS, D1_QUERIES]);
expect(query).toContain('$accountTag: String!');
expect(query).toContain('$filter: JSON!');
expect(query).toContain('accounts(filter: { accountTag: $accountTag })');
expect(query).toContain('workers_invocations: workersInvocationsAdaptive(');
expect(query).toContain('d1_queries: d1AnalyticsAdaptiveGroups(');
});
it('appends a workersInvocationsScheduled selection when requested', () => {
const query = buildBatchedAccountQuery([WORKERS_INVOCATIONS], true);
expect(query).toContain('workers_scheduled: workersInvocationsScheduled(');
expect(query).toContain('scriptName');
expect(query).toContain('cpuTimeUs');
});
it('builds a query for every account-scope dataset in the registry', () => {
const accountDatasets = ALL_DATASETS.filter((d) => (d.scope ?? 'account') === 'account');
const query = buildBatchedAccountQuery(accountDatasets, true);
for (const dataset of accountDatasets) {
expect(query).toContain(`${dataset.key}: ${dataset.field}(`);
}
});
});
describe('buildBatchedZoneQuery', () => {
const dataset: DatasetQuery = {
key: 'zone_detail',
measurement: 'cf_zone_detail',
field: 'httpRequestsAdaptiveGroups',
scope: 'zone',
dimensions: ['datetimeMinute'],
blocks: { sum: ['edgeResponseBytes'] },
tags: [],
fields: { bytes: { type: 'int', source: ['sum', 'edgeResponseBytes'] } },
};
it('aliases each zone with an inlined zoneTag literal', () => {
const query = buildBatchedZoneQuery(['zone-a', 'zone-b'], dataset);
expect(query).toContain('z0: zones(filter: { zoneTag: "zone-a" })');
expect(query).toContain('z1: zones(filter: { zoneTag: "zone-b" })');
expect(query).toContain('httpRequestsAdaptiveGroups(');
});
it('rejects zone tags with unsafe characters', () => {
expect(() => buildBatchedZoneQuery(['zone-a"; evil'], dataset)).toThrow(/Invalid zoneTag/);
});
});
describe('buildFilterObject', () => {
const range = {
start: new Date('2026-04-10T12:00:00Z'),
end: new Date('2026-04-10T12:10:00Z'),
};
it('uses datetime_geq / datetime_leq by default', () => {
expect(buildFilterObject(WORKERS_INVOCATIONS, range)).toEqual({
datetime_geq: '2026-04-10T12:00:00.000Z',
datetime_leq: '2026-04-10T12:10:00.000Z',
});
});
it('falls back to datetime filters when no dataset is supplied', () => {
expect(buildFilterObject(null, range)).toEqual({
datetime_geq: '2026-04-10T12:00:00.000Z',
datetime_leq: '2026-04-10T12:10:00.000Z',
});
});
});
describe('groupErrorsByAlias', () => {
it('maps GraphQL error paths to aliased field names', () => {
const errors = [
{ message: 'boom', path: ['viewer', 'accounts', 0, 'workers_invocations'] },
{ message: 'bang', path: ['viewer', 'accounts', 0, 'd1_queries'] },
];
expect(groupErrorsByAlias(errors)).toEqual({
workers_invocations: 'boom',
d1_queries: 'bang',
});
});
it('returns an empty map when errors is null or undefined', () => {
expect(groupErrorsByAlias(null)).toEqual({});
// eslint-disable-next-line unicorn/no-useless-undefined
expect(groupErrorsByAlias(undefined)).toEqual({});
});
});
describe('CloudflareGraphQLClient', () => {
it('sends the bearer token and parses batched dataset rows', async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: {
viewer: {
accounts: [
{
workers_invocations: [
{
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z', scriptName: 'a' },
sum: { requests: 10 },
},
],
},
],
},
},
errors: null,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
);
const client = new CloudflareGraphQLClient(
'tok',
'https://example.com/graphql',
fetchMock as unknown as typeof fetch,
);
const result = await client.fetchAccountBatch('acct', [WORKERS_INVOCATIONS], {
start: new Date('2026-04-10T12:00:00Z'),
end: new Date('2026-04-10T12:10:00Z'),
});
expect(result.rows.workers_invocations).toHaveLength(1);
expect(result.rows.workers_invocations?.[0].dimensions.scriptName).toBe('a');
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const init = call[1];
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok');
const body = JSON.parse(init.body as string);
expect(body.variables.accountTag).toBe('acct');
expect(body.query).toContain('workers_invocations: workersInvocationsAdaptive');
});
it('throws a CloudflareGraphQLError on HTTP failure', async () => {
const fetchMock = vi.fn(() => Promise.resolve(new Response('boom', { status: 500 })));
const client = new CloudflareGraphQLClient(
'tok',
'https://example.com/graphql',
fetchMock as unknown as typeof fetch,
);
await expect(
client.fetchAccountBatch('acct', [WORKERS_INVOCATIONS], { start: new Date(0), end: new Date(1) }),
).rejects.toBeInstanceOf(CloudflareGraphQLError);
});
it('surfaces per-field errors from a partial GraphQL response', async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: {
viewer: {
accounts: [
{
workers_invocations: [
{
dimensions: { datetimeMinute: '2026-04-10T12:00:00Z' },
sum: { requests: 1 },
},
],
d1_queries: null,
},
],
},
},
errors: [{ message: 'no access', path: ['viewer', 'accounts', 0, 'd1_queries'] }],
}),
{ status: 200 },
),
),
);
const client = new CloudflareGraphQLClient(
'tok',
'https://example.com/graphql',
fetchMock as unknown as typeof fetch,
);
const result = await client.fetchAccountBatch('acct', [WORKERS_INVOCATIONS, D1_QUERIES], {
start: new Date(0),
end: new Date(1),
});
expect(result.rows.workers_invocations).toHaveLength(1);
expect(result.errors.d1_queries).toBe('no access');
});
it('splits account batches into chunks under the graphql node limit', async () => {
// Build a fake dataset list that's large enough to span multiple chunks.
const datasets = Array.from({ length: 60 }, (_, i) => ({
...WORKERS_INVOCATIONS,
key: `ds_${i}`,
measurement: `cf_ds_${i}`,
}));
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string);
// Return the aliased fields that this chunk actually asked for.
const aliases = [...body.query.matchAll(/(ds_\d+): workersInvocationsAdaptive/g)].map((m) => m[1]);
const account: Record<string, unknown> = {};
for (const alias of aliases) {
account[alias] = [];
}
return Promise.resolve(
new Response(JSON.stringify({ data: { viewer: { accounts: [account] } }, errors: null }), { status: 200 }),
);
});
const client = new CloudflareGraphQLClient(
'tok',
'https://example.com/graphql',
fetchMock as unknown as typeof fetch,
);
const result = await client.fetchAccountBatch('acct', datasets, {
start: new Date('2026-04-10T12:00:00Z'),
end: new Date('2026-04-10T12:10:00Z'),
});
// Each chunk is its own HTTP subrequest.
expect(fetchMock.mock.calls.length).toBeGreaterThan(1);
// All datasets get a row entry, even if empty — nothing is dropped across chunks.
expect(Object.keys(result.rows)).toHaveLength(60);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('only includes the scheduled invocations field on the first chunk', async () => {
// 60 datasets span 3 chunks at the default chunk size of 25.
const datasets = Array.from({ length: 60 }, (_, i) => ({
...WORKERS_INVOCATIONS,
key: `ds_${i}`,
measurement: `cf_ds_${i}`,
}));
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string);
const aliases = [...body.query.matchAll(/(ds_\d+): workersInvocationsAdaptive/g)].map((m) => m[1]);
const account: Record<string, unknown> = {};
for (const alias of aliases) {
account[alias] = [];
}
// When this chunk includes the scheduled feed, echo a single row back
// so we can verify the collector only sees it once.
if (body.query.includes('workers_scheduled: workersInvocationsScheduled')) {
(account as Record<string, unknown>).workers_scheduled = [
{
scriptName: 's',
cron: '* * * * *',
status: 'success',
datetime: '2026-04-10T12:00:00Z',
cpuTimeUs: 100,
},
];
}
return Promise.resolve(
new Response(JSON.stringify({ data: { viewer: { accounts: [account] } }, errors: null }), { status: 200 }),
);
});
const client = new CloudflareGraphQLClient(
'tok',
'https://example.com/graphql',
fetchMock as unknown as typeof fetch,
);
const result = await client.fetchAccountBatch(
'acct',
datasets,
{
start: new Date('2026-04-10T12:00:00Z'),
end: new Date('2026-04-10T12:10:00Z'),
},
{ includeScheduledInvocations: true },
);
// Exactly one chunk must include the scheduled-invocations selection;
// the others should not ask for it at all.
const scheduledCount = fetchMock.mock.calls.filter((call) => {
const body = JSON.parse((call[1] as RequestInit).body as string);
return (body.query as string).includes('workers_scheduled: workersInvocationsScheduled');
}).length;
expect(scheduledCount).toBe(1);
// And the merged result should carry exactly one scheduled row.
expect(result.scheduledInvocations).toHaveLength(1);
});
it('batches multiple zones into a single request', async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: {
viewer: {
z0: [
{
httpRequestsAdaptiveGroups: [{ dimensions: { datetimeMinute: '2026-04-10T12:00:00Z' }, count: 10 }],
},
],
z1: [
{
httpRequestsAdaptiveGroups: [{ dimensions: { datetimeMinute: '2026-04-10T12:00:00Z' }, count: 20 }],
},
],
},
},
errors: null,
}),
{ status: 200 },
),
),
);
const zoneScoped: DatasetQuery = {
key: 'zone_detail',
measurement: 'cf_zone_detail',
field: 'httpRequestsAdaptiveGroups',
scope: 'zone',
dimensions: ['datetimeMinute'],
topLevelFields: ['count'],
blocks: {},
tags: [],
fields: { requests: { type: 'int', source: ['_top', 'count'] } },
};
const client = new CloudflareGraphQLClient(
'tok',
'https://example.com/graphql',
fetchMock as unknown as typeof fetch,
);
const result = await client.fetchZoneBatch(['zone-a', 'zone-b'], zoneScoped, {
start: new Date(0),
end: new Date(1),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.rows['zone-a']).toHaveLength(1);
expect(result.rows['zone-b']).toHaveLength(1);
expect(result.rows['zone-a']?.[0].count).toBe(10);
expect(result.rows['zone-b']?.[0].count).toBe(20);
});
it('counts graphql requests and error responses across multiple chunks', async () => {
const datasets = Array.from({ length: 30 }, (_, i) => ({
...WORKERS_INVOCATIONS,
key: `ds_${i}`,
measurement: `cf_ds_${i}`,
}));
let calls = 0;
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
calls++;
const body = JSON.parse(init.body as string);
const aliases = [...body.query.matchAll(/(ds_\d+): workersInvocationsAdaptive/g)].map((m) => m[1]);
const account: Record<string, unknown> = {};
for (const alias of aliases) {
account[alias] = [];
}
// Return an error payload on the second chunk so `errorResponseCount`
// has something to count.
const errors =
calls === 2 ? [{ message: 'transient', path: ['viewer', 'accounts', 0, aliases[0] ?? 'ds_0'] }] : null;
return Promise.resolve(
new Response(JSON.stringify({ data: { viewer: { accounts: [account] } }, errors }), { status: 200 }),
);
});
const client = new CloudflareGraphQLClient(
'tok',
'https://example.com/graphql',
fetchMock as unknown as typeof fetch,
);
await client.fetchAccountBatch('acct', datasets, {
start: new Date('2026-04-10T12:00:00Z'),
end: new Date('2026-04-10T12:10:00Z'),
});
expect(client.requestCount).toBe(fetchMock.mock.calls.length);
expect(client.requestCount).toBeGreaterThanOrEqual(2);
expect(client.errorResponseCount).toBe(1);
});
});
@@ -0,0 +1,285 @@
import {
ACCOUNT_BATCH_CHUNK_SIZE,
buildBatchedAccountQuery,
buildBatchedZoneQuery,
buildFilterObject,
chunkArray,
groupErrorsByAlias,
} from './graphql-builders.js';
import type { AccountQueryResult, DatasetQuery, DatasetRow, GraphQLResponse } from './types.js';
const CLOUDFLARE_GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql';
export class CloudflareGraphQLError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly responseBody?: string,
) {
super(message);
this.name = 'CloudflareGraphQLError';
}
}
export interface ScheduledWorkerInvocation {
scriptName: string;
cron: string;
status: string;
datetime: string;
cpuTimeUs: number;
}
export interface BatchedDatasetResult {
rows: Record<string, DatasetRow[]>;
errors: Record<string, string>;
scheduledInvocations?: ScheduledWorkerInvocation[];
}
export interface BatchedZoneDatasetResult {
rows: Record<string, DatasetRow[]>;
errors: Record<string, string>;
}
export interface ICloudflareGraphQLClient {
fetchAccountBatch(
accountTag: string,
datasets: readonly DatasetQuery[],
range: { start: Date; end: Date },
options?: { includeScheduledInvocations?: boolean },
): Promise<BatchedDatasetResult>;
fetchZoneBatch(
zoneTags: readonly string[],
dataset: DatasetQuery,
range: { start: Date; end: Date },
): Promise<BatchedZoneDatasetResult>;
}
export class CloudflareGraphQLClient implements ICloudflareGraphQLClient {
private _requestCount = 0;
private _errorResponseCount = 0;
private _retryCount = 0;
private _retrySuccessCount = 0;
constructor(
private readonly apiToken: string,
private readonly endpoint: string = CLOUDFLARE_GRAPHQL_ENDPOINT,
private readonly fetchImpl?: typeof fetch,
) {}
get requestCount(): number {
return this._requestCount;
}
get errorResponseCount(): number {
return this._errorResponseCount;
}
get retryCount(): number {
return this._retryCount;
}
get retrySuccessCount(): number {
return this._retrySuccessCount;
}
async fetchAccountBatch(
accountTag: string,
datasets: readonly DatasetQuery[],
range: { start: Date; end: Date },
options: { includeScheduledInvocations?: boolean } = {},
): Promise<BatchedDatasetResult> {
// Chunk under the ~50-dataset ceiling enforced by Cloudflare's GraphQL endpoint.
const chunks = chunkArray(datasets, ACCOUNT_BATCH_CHUNK_SIZE);
if (chunks.length === 0 && options.includeScheduledInvocations) {
chunks.push([]);
}
const result: BatchedDatasetResult = { rows: {}, errors: {} };
for (const [i, chunk] of chunks.entries()) {
const includeScheduled = i === 0 && (options.includeScheduledInvocations ?? false);
const chunkResult = await this.fetchAccountBatchChunk(accountTag, chunk, range, includeScheduled);
Object.assign(result.rows, chunkResult.rows);
Object.assign(result.errors, chunkResult.errors);
if (includeScheduled) {
if (chunkResult.errors.workers_scheduled) {
result.errors.workers_scheduled = chunkResult.errors.workers_scheduled;
} else {
result.scheduledInvocations = chunkResult.scheduledInvocations ?? [];
}
}
}
return result;
}
private async fetchAccountBatchChunk(
accountTag: string,
datasets: readonly DatasetQuery[],
range: { start: Date; end: Date },
includeScheduledInvocations: boolean,
attempt = 1,
): Promise<BatchedDatasetResult> {
const query = buildBatchedAccountQuery(datasets, includeScheduledInvocations);
const variables = {
accountTag,
filter: buildFilterObject(datasets[0] ?? null, range),
};
const { data, errors } = await this.executeAllowPartial<AccountQueryResult>(query, variables);
const result: BatchedDatasetResult = { rows: {}, errors: {} };
const errorsByAlias = groupErrorsByAlias(errors);
const account = data?.viewer.accounts[0];
for (const dataset of datasets) {
if (errorsByAlias[dataset.key]) {
result.errors[dataset.key] = errorsByAlias[dataset.key];
continue;
}
const rows = account?.[dataset.key] as unknown as DatasetRow[] | undefined;
result.rows[dataset.key] = rows ?? [];
}
if (includeScheduledInvocations) {
if (errorsByAlias.workers_scheduled) {
result.errors.workers_scheduled = errorsByAlias.workers_scheduled;
} else {
result.scheduledInvocations = (account?.workers_scheduled as unknown as ScheduledWorkerInvocation[]) ?? [];
}
}
// If the query returned no data and no aliased errors, surface a single
// top-level error so callers can report it uniformly.
if (!account && errors && errors.length > 0 && Object.keys(result.errors).length === 0) {
const message = errors.map((e) => e.message).join('; ');
for (const dataset of datasets) {
result.errors[dataset.key] = message;
}
if (includeScheduledInvocations) {
result.errors.workers_scheduled = message;
}
}
// Retry once when all fields errored — likely a transient Cloudflare
// analytics backend issue that resolves on the next attempt.
const totalFields = datasets.length + (includeScheduledInvocations ? 1 : 0);
if (attempt < 2 && totalFields > 0 && Object.keys(result.errors).length >= totalFields) {
this._retryCount++;
console.warn(`[graphql] all ${totalFields} fields errored, retrying chunk (attempt ${attempt + 1})`);
// Undo the error response count from this attempt — the retry will
// re-increment if it also fails, keeping the counter accurate.
this._errorResponseCount--;
await sleep(250);
const retryResult = await this.fetchAccountBatchChunk(
accountTag,
datasets,
range,
includeScheduledInvocations,
attempt + 1,
);
if (Object.keys(retryResult.errors).length < totalFields) {
this._retrySuccessCount++;
}
return retryResult;
}
return result;
}
async fetchZoneBatch(
zoneTags: readonly string[],
dataset: DatasetQuery,
range: { start: Date; end: Date },
attempt = 1,
): Promise<BatchedZoneDatasetResult> {
if (zoneTags.length === 0) {
return { rows: {}, errors: {} };
}
const query = buildBatchedZoneQuery(zoneTags, dataset);
const variables = {
filter: buildFilterObject(dataset, range),
};
const { data, errors } = await this.executeAllowPartial<{
viewer: Record<string, Array<Record<string, DatasetRow[]>>>;
}>(query, variables);
const result: BatchedZoneDatasetResult = { rows: {}, errors: {} };
const errorsByAlias = groupErrorsByAlias(errors);
const viewer = data?.viewer ?? {};
for (const [index, zoneTag] of zoneTags.entries()) {
const alias = `z${index}`;
if (errorsByAlias[alias]) {
result.errors[zoneTag] = errorsByAlias[alias];
continue;
}
const zoneBlock = viewer[alias]?.[0];
const rows = zoneBlock?.[dataset.field] ?? [];
result.rows[zoneTag] = rows;
}
if (errors && errors.length > 0 && Object.keys(result.errors).length === 0) {
const message = errors.map((e) => e.message).join('; ');
for (const zoneTag of zoneTags) {
result.errors[zoneTag] = message;
}
}
// Retry once when all zones errored.
if (attempt < 2 && zoneTags.length > 0 && Object.keys(result.errors).length >= zoneTags.length) {
this._retryCount++;
console.warn(
`[graphql] all ${zoneTags.length} zones errored for ${dataset.key}, retrying (attempt ${attempt + 1})`,
);
this._errorResponseCount--;
await sleep(250);
const retryResult = await this.fetchZoneBatch(zoneTags, dataset, range, attempt + 1);
if (Object.keys(retryResult.errors).length < zoneTags.length) {
this._retrySuccessCount++;
}
return retryResult;
}
return result;
}
async executeAllowPartial<T>(query: string, variables: Record<string, unknown>): Promise<GraphQLResponse<T>> {
const doFetch = this.fetchImpl ?? globalThis.fetch;
if (typeof doFetch !== 'function') {
throw new TypeError(`fetch is not a function (typeof=${typeof doFetch})`);
}
this._requestCount++;
const response = await doFetch(this.endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
const body = await safeReadText(response);
throw new CloudflareGraphQLError(`Cloudflare GraphQL HTTP error (${response.status})`, response.status, body);
}
const parsed = (await response.json()) as GraphQLResponse<T>;
if (parsed.errors && parsed.errors.length > 0) {
this._errorResponseCount++;
const uniqueMessages = [...new Set(parsed.errors.map((e) => e.message))];
const paths = parsed.errors.slice(0, 5).map((e) => e.path?.join('.') ?? 'unknown');
console.warn(
`[graphql] response contained ${parsed.errors.length} errors:`,
uniqueMessages.join('; '),
`(paths: ${paths.join(', ')}${parsed.errors.length > 5 ? ', ...' : ''})`,
);
}
return parsed;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function safeReadText(response: Response): Promise<string | undefined> {
try {
return await response.text();
} catch {
return undefined;
}
}
@@ -0,0 +1,98 @@
import { createExecutionContext, createScheduledController, env, SELF, waitOnExecutionContext } from 'cloudflare:test';
import { beforeEach, describe, expect, it } from 'vitest';
import { __resetFlushStateForTests } from './flush-state.js';
import worker from './index.js';
describe('HTTP handler', () => {
it('returns 200 for /health', async () => {
const response = await SELF.fetch('https://example.com/health');
expect(response.status).toBe(200);
const body = (await response.json()) as { status: string };
expect(body.status).toBe('ok');
});
it('returns 404 for unknown routes', async () => {
const response = await SELF.fetch('https://example.com/nope');
expect(response.status).toBe(404);
});
it('returns 404 for /collect — the manual trigger endpoint has been removed', async () => {
const response = await SELF.fetch('https://example.com/collect');
expect(response.status).toBe(404);
});
});
describe('scheduled handler', () => {
beforeEach(() => {
__resetFlushStateForTests();
});
it('emits cron_error{reason=missing_config} when API token or account ID are missing', async () => {
// Collect metric flush bodies seen by the victoria-metrics POST so we
// can assert on the cron_error line without touching internal state.
const originalFetch = globalThis.fetch;
const flushBodies: string[] = [];
globalThis.fetch = ((url: RequestInfo | URL, init?: RequestInit) => {
const target = typeof url === 'string' ? url : url.toString();
if (target.includes('/write')) {
flushBodies.push(init?.body as string);
return Promise.resolve(new Response('', { status: 204 }));
}
return Promise.resolve(new Response('', { status: 200 }));
}) as typeof fetch;
const controller = createScheduledController();
const ctx = createExecutionContext();
try {
// env has no CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID bindings
// in the test config, so this hits the early-return missing-config
// branch.
await worker.scheduled?.(controller, env as unknown as Env, ctx);
await waitOnExecutionContext(ctx);
} finally {
globalThis.fetch = originalFetch;
}
const combined = flushBodies.join('\n');
expect(combined).toContain('cloudflare_metrics_cron_error');
expect(combined).toContain('reason=missing_config');
});
it('pushes flush self-telemetry to victoria-metrics on the next tick', async () => {
// First tick: a successful flush populates lastFlushStats.
// Second tick: takeLastFlushStats() drains it and emits
// cloudflare_metrics_flush_* lines to the second tick's flush body.
const originalFetch = globalThis.fetch;
const flushBodies: string[] = [];
globalThis.fetch = ((url: RequestInfo | URL, init?: RequestInit) => {
const target = typeof url === 'string' ? url : url.toString();
if (target.includes('/write')) {
flushBodies.push(init?.body as string);
return Promise.resolve(new Response('', { status: 204 }));
}
return Promise.resolve(new Response('', { status: 200 }));
}) as typeof fetch;
try {
const controller = createScheduledController();
// Tick 1 — missing-config path still triggers a flush.
const ctx1 = createExecutionContext();
await worker.scheduled?.(controller, env as unknown as Env, ctx1);
await waitOnExecutionContext(ctx1);
// Tick 2 — should emit the flush self-telemetry from tick 1's flush.
const ctx2 = createExecutionContext();
await worker.scheduled?.(controller, env as unknown as Env, ctx2);
await waitOnExecutionContext(ctx2);
} finally {
globalThis.fetch = originalFetch;
}
expect(flushBodies.length).toBeGreaterThanOrEqual(2);
// The second body should contain flush-* lines driven by lastFlushStats
// captured during the first flush.
const secondBody = flushBodies[1] ?? '';
expect(secondBody).toContain('cloudflare_metrics_flush');
expect(secondBody).toMatch(/cloudflare_metrics_flush[^\s]*status=ok/);
});
});
@@ -0,0 +1,88 @@
import { DeferredRepository } from '../deferred.js';
import { HeaderMetricsProvider, InfluxMetricsProvider } from '../metric-providers.js';
import { Metric } from '../metric.js';
import { CloudflareMetricsRepository } from '../metrics.js';
export async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const deferredRepository = new DeferredRepository(ctx);
const headerProvider = new HeaderMetricsProvider();
const influxProvider = new InfluxMetricsProvider(env.VMETRICS_API_TOKEN ?? '', env.ENVIRONMENT ?? '');
deferredRepository.defer(() => influxProvider.flush());
const metrics = new CloudflareMetricsRepository(
'cloudflare_metrics',
request,
[influxProvider, headerProvider],
env.ENVIRONMENT ?? '',
);
try {
const response = await metrics.monitorAsyncFunction({ name: 'handle_request' }, () => routeRequest(request))();
const url = new URL(request.url);
metrics.push(
Metric.create('http_response')
.addTag('method', request.method)
.addTag('path', url.pathname)
.addTag('status', String(response.status))
.intField('count', 1),
);
response.headers.set('Server-Timing', headerProvider.getTimingHeader());
deferredRepository.runDeferred();
return response;
} catch (error) {
console.error(error);
metrics.push(
Metric.create('http_response')
.addTag('method', request.method)
.addTag('path', new URL(request.url).pathname)
.addTag('status', '500')
.intField('count', 1),
);
deferredRepository.runDeferred();
return errorResponse('Internal Server Error', 500);
}
}
function routeRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method === 'OPTIONS') {
return Promise.resolve(
new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
},
}),
);
}
switch (url.pathname) {
case '/health': {
return Promise.resolve(jsonResponse({ status: 'ok' }));
}
default: {
return Promise.resolve(errorResponse('Not Found', 404));
}
}
}
const DEFAULT_HEADERS: Record<string, string> = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
};
function jsonResponse(data: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
return new Response(JSON.stringify(data), {
status,
headers: { ...DEFAULT_HEADERS, ...extraHeaders },
});
}
function errorResponse(error: string, status: number) {
return jsonResponse({ error }, status);
}
@@ -0,0 +1,166 @@
import { CloudflareRestClient } from '../cloudflare-api.js';
import { CloudflareMetricsCollector } from '../collector.js';
import { ALL_DATASETS } from '../datasets.js';
import { takeLastFlushStats } from '../flush-state.js';
import { CloudflareGraphQLClient } from '../graphql-client.js';
import { InfluxMetricsProvider } from '../metric-providers.js';
import { Metric } from '../metric.js';
import { CloudflareMetricsRepository } from '../metrics.js';
// Lazy-init on first handler call because Date.now() at module scope
// returns a frozen value in Workers (deploy time, not wall clock).
let isolateStartedAt: number | null = null;
// Track the end timestamp of the last successful collection so we can
// backfill the exact gap after crashes or missed ticks. Resets on isolate
// restart, in which case we fall back to a wide backfill window.
let lastSuccessfulEndMs: number | null = null;
export async function handleScheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
isolateStartedAt ??= Date.now();
// Cloudflare occasionally drops or delays cron triggers. When it recovers
// from a gap it fires the missed triggers in a burst (~10 at once in
// practice) all at the same wall-clock time. If we used Date.now() for
// the query window, every catch-up invocation would query the same
// "now - lag" range and the originally-scheduled minutes would be lost.
// controller.scheduledTime is the time the cron was originally scheduled
// for, so each catch-up tick covers its own intended window.
const scheduledMs = controller.scheduledTime;
const influxProvider = new InfluxMetricsProvider(env.VMETRICS_API_TOKEN ?? '', env.ENVIRONMENT ?? '');
const request = new Request('https://localhost/cron');
const metrics = new CloudflareMetricsRepository(
'cloudflare_metrics',
request,
[influxProvider],
env.ENVIRONMENT ?? '',
);
// Emit telemetry from the previous tick's flush (can't observe the flush
// we're about to make from inside it). `takeLastFlushStats` clears after
// read so a tick without a prior flush just skips these metrics.
emitLastFlushStats(metrics);
if (!env.CLOUDFLARE_API_TOKEN || !env.CLOUDFLARE_ACCOUNT_ID) {
console.error('[cron] Missing CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID — skipping collection');
metrics.push(Metric.create('cron_error').addTag('reason', 'missing_config').intField('count', 1));
ctx.waitUntil(influxProvider.flush());
return;
}
const isolateAgeSec = Math.round((Date.now() - isolateStartedAt) / 1000);
const isColdStart = isolateAgeSec < 120;
const graphqlClient = new CloudflareGraphQLClient(env.CLOUDFLARE_API_TOKEN);
try {
const results = await metrics.monitorAsyncFunction({ name: 'cron_collect' }, () =>
runCollection(env, metrics, graphqlClient, isColdStart, lastSuccessfulEndMs, scheduledMs),
)();
// Record the lagged end of the window we just queried so the next tick
// knows where to pick up if there was a gap. Use scheduledMs (not wall
// clock) so catch-up invocations advance the pointer in order.
const prevEnd = lastSuccessfulEndMs;
const thisEnd = scheduledMs - DEFAULT_LAG_MS;
if (lastSuccessfulEndMs === null || thisEnd > lastSuccessfulEndMs) {
lastSuccessfulEndMs = thisEnd;
}
const totalPoints = results.reduce((acc, r) => acc + r.points, 0);
const totalErrors = results.filter((r) => r.error).length;
const wasBackfill = isColdStart || prevEnd === null;
metrics.push(
Metric.create('cron_summary')
.intField('datasets', results.length)
.intField('points', totalPoints)
.intField('errors', totalErrors)
.intField('cold_start', isColdStart ? 1 : 0),
);
console.log(
`[cron] collected ${totalPoints} points across ${results.length} datasets (${totalErrors} errors${wasBackfill ? ', backfill' : ''})`,
);
} catch (error) {
console.error('[cron] Collection failed:', error);
metrics.push(
Metric.create('cron_error')
.addTag('reason', error instanceof Error ? error.name : 'unknown')
.intField('count', 1),
);
}
for (const m of [
Metric.create('graphql_client')
.intField('requests', graphqlClient.requestCount)
.intField('error_responses', graphqlClient.errorResponseCount)
.intField('retries', graphqlClient.retryCount)
.intField('retry_successes', graphqlClient.retrySuccessCount),
Metric.create('isolate').intField('age_seconds', isolateAgeSec),
]) {
metrics.push(m);
}
ctx.waitUntil(influxProvider.flush());
}
function emitLastFlushStats(metrics: CloudflareMetricsRepository): void {
const stats = takeLastFlushStats();
if (!stats) {
return;
}
metrics.push(
Metric.create('flush')
.addTag('status', stats.status)
.intField('bytes', stats.bytes)
.intField('duration_ms', Math.round(stats.durationMs))
.intField('pending_buffers', stats.pendingBuffers)
.intField('pending_bytes', stats.pendingBytes)
.intField('errors', stats.status === 'error' ? 1 : 0)
.intField('count', 1),
);
}
// Re-export for use in window calculation. Must stay in sync with the
// collector's default — but avoids a circular import.
const DEFAULT_LAG_MS = 5 * 60 * 1000;
const DEFAULT_WINDOW_MS = 5 * 60 * 1000;
const MAX_BACKFILL_MS = 30 * 60 * 1000; // 30-minute cap on recovery backfill
function computeWindowMs(isColdStart: boolean, lastSuccessfulEndMs: number | null, scheduledMs: number): number {
if (lastSuccessfulEndMs !== null) {
// We know exactly when we last queried — extend the window to cover the
// gap. The collector's end = scheduled - lag, so the gap is
// (scheduled - lag) - lastEnd. Using scheduled time (not wall clock)
// so catch-up invocations compute the gap correctly.
const gapMs = scheduledMs - DEFAULT_LAG_MS - lastSuccessfulEndMs;
if (gapMs > DEFAULT_WINDOW_MS) {
return Math.min(gapMs, MAX_BACKFILL_MS);
}
return DEFAULT_WINDOW_MS;
}
// Fresh isolate with no prior state — use a shorter backfill window
// since cold-start ticks already do extra work resolving resource caches.
if (isColdStart) {
return 15 * 60 * 1000;
}
return DEFAULT_WINDOW_MS;
}
async function runCollection(
env: Env,
metrics: CloudflareMetricsRepository,
graphqlClient: CloudflareGraphQLClient,
isColdStart: boolean,
prevEndMs: number | null,
scheduledMs: number,
) {
const restClient = new CloudflareRestClient(env.CLOUDFLARE_API_TOKEN ?? '');
const windowMs = computeWindowMs(isColdStart, prevEndMs, scheduledMs);
// Anchor the collector's "now" to the cron's scheduled time, not wall
// clock. This keeps catch-up invocations (fired late by Cloudflare)
// querying their originally-intended window instead of whatever the
// wall clock says at recovery time.
const collector = new CloudflareMetricsCollector(graphqlClient, env.CLOUDFLARE_ACCOUNT_ID, metrics, {
restClient,
windowMs,
now: () => new Date(scheduledMs),
});
return collector.collectAll(ALL_DATASETS);
}
+12
View File
@@ -0,0 +1,12 @@
import { handleFetch } from './handlers/http.js';
import { handleScheduled } from './handlers/scheduled.js';
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return handleFetch(request, env, ctx);
},
scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
return handleScheduled(controller, env, ctx);
},
};
@@ -0,0 +1,150 @@
import { env } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';
import { CloudflareMetricsCollector } from './collector.js';
import { ALL_DATASETS } from './datasets.js';
import { CloudflareGraphQLClient } from './graphql-client.js';
import type { IMetricsProviderRepository } from './metric-providers.js';
import { Metric } from './metric.js';
import { CloudflareMetricsRepository } from './metrics.js';
import type { DatasetRow } from './types.js';
/**
* Integration tests: these hit the real Cloudflare GraphQL Analytics API.
*
* They are gated on `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` being
* set (see `vitest.integration.config.ts` — the pool passes the variables
* through as miniflare bindings). Run with:
*
* CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... \
* pnpm --filter @immich-services/cloudflare-metrics run test:integration
*/
const hasCredentials = Boolean(env.CLOUDFLARE_API_TOKEN && env.CLOUDFLARE_ACCOUNT_ID);
class RecordingProvider implements IMetricsProviderRepository {
readonly metrics: Metric[] = [];
pushMetric(metric: Metric) {
this.metrics.push(metric);
}
flush() {
/* noop */
}
}
function buildClient() {
return new CloudflareGraphQLClient(env.CLOUDFLARE_API_TOKEN ?? '');
}
function buildMetricsRepo(provider: IMetricsProviderRepository) {
return new CloudflareMetricsRepository(
'cloudflare_metrics',
new Request('https://localhost/integration'),
[provider],
'',
);
}
function wideRange() {
// Use a 1h lookback window ending 10m ago so we land in a fully
// populated bucket without pulling an unreasonable amount of data.
const end = new Date(Date.now() - 10 * 60 * 1000);
const start = new Date(end.getTime() - 60 * 60 * 1000);
return { start, end };
}
const INTEGRATION_TIMEOUT_MS = 60_000;
describe.skipIf(!hasCredentials)('Cloudflare GraphQL integration', () => {
const client = buildClient();
// Account-scoped datasets are fetched in a single batched request,
// then iterated individually so we can assert on the shape per dataset.
// Zone-scoped datasets are tested separately below.
const accountDatasets = ALL_DATASETS.filter((d) => (d.scope ?? 'account') === 'account');
it(
'fetches every account-scope dataset in a single batched request',
async () => {
const range = wideRange();
const result = await client.fetchAccountBatch(env.CLOUDFLARE_ACCOUNT_ID ?? '', accountDatasets, range, {
includeScheduledInvocations: true,
});
for (const dataset of accountDatasets) {
const rows = result.rows[dataset.key] as DatasetRow[] | undefined;
const err = result.errors[dataset.key];
if (err) {
// Plan-gated datasets (e.g. firewallEventsAdaptiveGroups) return an
// authz error; we don't include those in the registry but if they
// ever get added we want a clear failure here.
throw new Error(`Dataset ${dataset.key} returned error: ${err}`);
}
expect(Array.isArray(rows), `rows for ${dataset.key} should be an array`).toBe(true);
if (!rows) {
continue;
}
for (const row of rows) {
expect(row.dimensions).toBeDefined();
const timestampDim = dataset.timestampDimension ?? 'datetimeMinute';
expect(row.dimensions[timestampDim]).toBeDefined();
for (const [, spec] of Object.entries(dataset.fields)) {
const [block, key] = spec.source;
if (block === '_top') {
continue;
}
const blockData = (row as unknown as Record<string, Record<string, unknown> | undefined>)[block];
if (blockData && key in blockData) {
const value = blockData[key];
expect(value === null || typeof value === 'number', `${dataset.key}.${block}.${key}`).toBe(true);
}
}
}
}
expect(result.scheduledInvocations).toBeDefined();
},
INTEGRATION_TIMEOUT_MS,
);
it(
'runs the full collector against the live account and emits measurements',
async () => {
const provider = new RecordingProvider();
const metrics = buildMetricsRepo(provider);
const collector = new CloudflareMetricsCollector(client, env.CLOUDFLARE_ACCOUNT_ID ?? '', metrics, {
// Narrower window — the goal is to verify the full pipeline works, not
// to fetch a full day of data.
lagMs: 5 * 60 * 1000,
windowMs: 60 * 60 * 1000,
});
const results = await collector.collectAll(ALL_DATASETS);
// At least one dataset should succeed against our account.
const successes = results.filter((r) => !r.error);
expect(successes.length).toBeGreaterThan(0);
// Every emitted metric should have a timestamp and an account_id tag.
const exported = provider.metrics.filter((m) => m.name.startsWith('cf_'));
for (const metric of exported) {
expect(metric.exportTimestamp).toBeDefined();
expect(metric.tags.get('account_id')).toBe(env.CLOUDFLARE_ACCOUNT_ID);
}
console.log(
`[integration] ${successes.length}/${results.length} datasets succeeded, emitted ${exported.length} metrics`,
);
for (const result of results) {
if (result.error) {
console.warn(`[integration] ${result.dataset} error: ${result.error}`);
}
}
},
INTEGRATION_TIMEOUT_MS,
);
});
describe.skipIf(hasCredentials)('Cloudflare GraphQL integration (skipped)', () => {
it('is skipped when CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID are unset', () => {
expect(hasCredentials).toBe(false);
});
});
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __resetFlushStateForTests } from './flush-state.js';
import { InfluxMetricsProvider } from './metric-providers.js';
import { Metric } from './metric.js';
function getLines(provider: InfluxMetricsProvider): string[] {
return (provider as unknown as { metrics: string[] }).metrics;
}
describe('InfluxDB line protocol escaping', () => {
beforeEach(() => {
__resetFlushStateForTests();
});
it('escapes commas in measurement names', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('my,measurement').intField('v', 1);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).toMatch(/^my\\,measurement /);
});
it('escapes spaces in measurement names', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('my measurement').intField('v', 1);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).toMatch(/^my\\ measurement /);
});
it('escapes commas, equals, and spaces in tag keys', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').addTag('k,e=y s', 'val').intField('v', 1);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).toContain(String.raw`k\,e\=y\ s=val`);
});
it('escapes commas, equals, and spaces in tag values', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').addTag('key', 'a,b=c d').intField('v', 1);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).toContain(String.raw`key=a\,b\=c\ d`);
});
it('escapes commas, equals, and spaces in field keys', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').intField('f,i=e d', 42);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).toContain(String.raw`f\,i\=e\ d=42i`);
});
it('handles measurement names with no special characters', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('simple_metric').intField('v', 1);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).toMatch(/^simple_metric /);
});
});
describe('InfluxDB line protocol NaN/Infinity handling', () => {
beforeEach(() => {
__resetFlushStateForTests();
});
it('skips NaN float fields', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').floatField('bad', Number.NaN).intField('good', 1);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).not.toContain('bad');
expect(line).toContain('good=1i');
});
it('skips Infinity float fields', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').floatField('bad', Infinity).intField('good', 1);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).not.toContain('bad');
expect(line).toContain('good=1i');
});
it('skips -Infinity int fields', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').intField('bad', -Infinity).intField('good', 2);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).not.toContain('bad');
expect(line).toContain('good=2i');
});
it('drops the entire line when all fields are NaN/Infinity', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').floatField('a', Number.NaN).floatField('b', Infinity);
provider.pushMetric(metric);
expect(getLines(provider)).toHaveLength(0);
});
it('keeps zero and negative values', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').intField('zero', 0).intField('neg', -5).floatField('frac', 0);
provider.pushMetric(metric);
const [line] = getLines(provider);
expect(line).toContain('zero=0i');
expect(line).toContain('neg=-5i');
expect(line).toContain('frac=0');
});
});
describe('InfluxDB line protocol timestamp', () => {
beforeEach(() => {
__resetFlushStateForTests();
});
it('appends nanosecond timestamp when exportTimestamp is set', () => {
const provider = new InfluxMetricsProvider('', '');
const ts = new Date('2026-04-10T12:00:00Z');
const metric = Metric.create('test').intField('v', 1).setExportTimestamp(ts);
provider.pushMetric(metric);
const [line] = getLines(provider);
const expectedNs = ts.getTime() * 1_000_000;
expect(line.endsWith(` ${expectedNs}`)).toBe(true);
});
it('omits timestamp when exportTimestamp is not set', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('test').intField('v', 1);
provider.pushMetric(metric);
const [line] = getLines(provider);
// Line should end with a field value, not a timestamp
expect(line).toMatch(/v=1i$/);
});
});
describe('getMetricsWriteUrl', () => {
it('uses prod URL for prod environment', () => {
const provider = new InfluxMetricsProvider('token', 'prod');
const url = (provider as unknown as { writeUrl: string }).writeUrl;
expect(url).toBe('https://cf-workers.monitoring.immich.cloud/write');
});
it('uses dev URL for empty environment', () => {
const provider = new InfluxMetricsProvider('token', '');
const url = (provider as unknown as { writeUrl: string }).writeUrl;
expect(url).toBe('https://cf-workers.monitoring.dev.immich.cloud/write');
});
it('uses staging URL for staging environment', () => {
const provider = new InfluxMetricsProvider('token', 'staging');
const url = (provider as unknown as { writeUrl: string }).writeUrl;
expect(url).toBe('https://cf-workers.monitoring.staging.immich.cloud/write');
});
});
@@ -0,0 +1,191 @@
import {
type LastFlushStats,
MAX_PENDING_FLUSH_BYTES,
pendingFlushBuffers,
recordLastFlushStats,
} from './flush-state.js';
import { Metric } from './metric.js';
export interface IMetricsProviderRepository {
pushMetric(metric: Metric): void;
flush(): void | Promise<void>;
}
export class HeaderMetricsProvider implements IMetricsProviderRepository {
private _metrics: string[] = [];
pushMetric(metric: Metric) {
for (const [label, { value, type }] of metric.fields) {
if (type === 'duration') {
const suffix = label === 'duration' ? '' : `_${label.replace('_duration', '')}`;
this._metrics.push(`${metric.name}${suffix};dur=${value}`);
}
}
}
getTimingHeader() {
return this._metrics.join(', ');
}
flush() {
// Header metrics are consumed via getTimingHeader(); nothing to log.
}
}
// InfluxDB line protocol escape rules. Keep these simple — we do a cheap
// `includes` check first and only fall into the replace when needed.
// Measurement: escape `,` and ` `.
// Tag key / tag value / field key: escape `,`, `=`, ` `.
function escapeMeasurement(s: string): string {
if (!s.includes(',') && !s.includes(' ')) {
return s;
}
return s.replaceAll(',', String.raw`\,`).replaceAll(' ', String.raw`\ `);
}
function escapeTagKey(s: string): string {
if (!s.includes(',') && !s.includes('=') && !s.includes(' ')) {
return s;
}
return s
.replaceAll(',', String.raw`\,`)
.replaceAll('=', String.raw`\=`)
.replaceAll(' ', String.raw`\ `);
}
function escapeTagValue(s: string): string {
if (!s.includes(',') && !s.includes('=') && !s.includes(' ')) {
return s;
}
return s
.replaceAll(',', String.raw`\,`)
.replaceAll('=', String.raw`\=`)
.replaceAll(' ', String.raw`\ `);
}
function getMetricsWriteUrl(environment: string): string {
if (environment === 'prod') {
return 'https://cf-workers.monitoring.immich.cloud/write';
}
return `https://cf-workers.monitoring.${environment || 'dev'}.immich.cloud/write`;
}
export class InfluxMetricsProvider implements IMetricsProviderRepository {
private metrics: string[] = [];
private writeUrl: string;
constructor(
private influxApiToken: string,
private environment: string,
) {
this.writeUrl = getMetricsWriteUrl(environment);
}
pushMetric(metric: Metric) {
// Build InfluxDB line protocol directly to avoid the overhead of the
// Point class (validation, type coercion, repeated escaping passes) on
// the hot path. Our inputs are already well-formed — no string fields,
// no nulls, no duplicate keys.
let line = escapeMeasurement(metric.name);
for (const [key, value] of metric.tags) {
line += ',' + escapeTagKey(key) + '=' + escapeTagValue(value);
}
const fieldParts: string[] = [];
for (const [key, { value, type }] of metric.fields) {
if (!Number.isFinite(value)) {
continue;
}
// int/duration → write as integer with `i` suffix
const serialized = type === 'float' ? value.toString() : Math.round(value).toString() + 'i';
fieldParts.push(escapeTagKey(key) + '=' + serialized);
}
const fieldPart = fieldParts.join(',');
if (!fieldPart) {
return; // no fields → skip
}
line += ' ' + fieldPart;
const exportTimestamp = metric.exportTimestamp;
if (exportTimestamp) {
// Line protocol uses nanosecond precision
line += ' ' + (exportTimestamp.getTime() * 1_000_000).toString();
}
this.metrics.push(line);
}
get pendingCount(): number {
return this.metrics.length;
}
async flush() {
const startedAt = performance.now();
const currentBody = this.metrics.join('\n');
this.metrics = [];
// Keep stashed retries separate until the POST succeeds so the
// eviction loop can drop oldest-first when the byte cap is crossed.
const parts: string[] = [...pendingFlushBuffers];
if (currentBody) {
parts.push(currentBody);
}
if (parts.length === 0) {
return;
}
const body = parts.join('\n');
let status: 'ok' | 'error' = 'ok';
// Non-prod body logging removed — payloads of ~3000 lines per tick
// blow the 256KB log budget and waste CPU on string serialization.
if (!this.influxApiToken) {
// No token (local/dev): treat as successful for cleanup.
pendingFlushBuffers.length = 0;
this.recordFlushStats(body, performance.now() - startedAt, status);
return;
}
try {
const response = await fetch(this.writeUrl, {
method: 'POST',
body,
headers: { Authorization: `Token ${this.influxApiToken}` },
});
await response.body?.cancel();
if (response.ok) {
pendingFlushBuffers.length = 0;
} else {
console.error('Failed to push metrics', response.status, response.statusText);
if (currentBody) {
this.stashFailedFlush(currentBody);
}
status = 'error';
}
} catch (error) {
console.error('Metric flush threw', error);
if (currentBody) {
this.stashFailedFlush(currentBody);
}
status = 'error';
}
this.recordFlushStats(body, performance.now() - startedAt, status);
}
private recordFlushStats(body: string, durationMs: number, status: 'ok' | 'error'): void {
const stats: LastFlushStats = {
bytes: body.length,
durationMs,
status,
pendingBuffers: pendingFlushBuffers.length,
pendingBytes: pendingFlushBuffers.reduce((acc, b) => acc + b.length, 0),
};
recordLastFlushStats(stats);
}
private stashFailedFlush(body: string): void {
pendingFlushBuffers.push(body);
let total = pendingFlushBuffers.reduce((acc, b) => acc + b.length, 0);
while (total > MAX_PENDING_FLUSH_BYTES && pendingFlushBuffers.length > 1) {
const dropped = pendingFlushBuffers.shift();
total -= dropped?.length ?? 0;
console.warn('[metrics] dropped buffered flush body over cap');
}
}
}
+73
View File
@@ -0,0 +1,73 @@
export type MetricFieldType = 'int' | 'float' | 'duration';
export class Metric {
private _tags = new Map<string, string>();
private _timestamp = performance.now();
private _exportTimestamp: Date | undefined;
private _fields = new Map<string, { value: number; type: MetricFieldType }>();
private constructor(private _name: string) {}
static create(name: string) {
return new Metric(name);
}
get tags() {
return this._tags;
}
get timestamp() {
return this._timestamp;
}
get exportTimestamp() {
return this._exportTimestamp;
}
get fields() {
return this._fields;
}
get name() {
return this._name;
}
prefixName(prefix: string) {
if (!this._name.startsWith(`${prefix}_`)) {
this._name = `${prefix}_${this._name}`;
}
}
addTag(key: string, value: string) {
this._tags.set(key, value);
return this;
}
addTags(tags: Record<string, string>) {
for (const [key, value] of Object.entries(tags)) {
this._tags.set(key, value);
}
return this;
}
durationField(key: string, duration?: number) {
this._fields.set(key, { value: duration ?? performance.now() - this._timestamp, type: 'duration' });
return this;
}
intField(key: string, value: number) {
this._fields.set(key, { value, type: 'int' });
return this;
}
floatField(key: string, value: number) {
this._fields.set(key, { value, type: 'float' });
return this;
}
// Override the wall-clock timestamp written to the metrics backend (vs
// _timestamp which is a monotonic anchor for durationField).
setExportTimestamp(date: Date) {
this._exportTimestamp = date;
return this;
}
}
+222
View File
@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __resetFlushStateForTests, takeLastFlushStats } from './flush-state.js';
import { InfluxMetricsProvider, type IMetricsProviderRepository } from './metric-providers.js';
import { Metric } from './metric.js';
import { CloudflareMetricsRepository } from './metrics.js';
describe('Metric', () => {
it('stores int, float, and duration fields with their types', () => {
const metric = Metric.create('test')
.addTag('foo', 'bar')
.intField('count', 5)
.floatField('ratio', 0.42)
.durationField('duration', 12.5);
expect(metric.name).toBe('test');
expect(metric.tags.get('foo')).toBe('bar');
expect(metric.fields.get('count')).toEqual({ value: 5, type: 'int' });
expect(metric.fields.get('ratio')).toEqual({ value: 0.42, type: 'float' });
expect(metric.fields.get('duration')).toEqual({ value: 12.5, type: 'duration' });
});
it('records an export timestamp override', () => {
const now = new Date('2026-04-10T12:00:00Z');
const metric = Metric.create('test').intField('v', 1).setExportTimestamp(now);
expect(metric.exportTimestamp).toEqual(now);
});
it('prefixes metric name only if not already prefixed', () => {
const metric = Metric.create('op');
metric.prefixName('svc');
metric.prefixName('svc');
expect(metric.name).toBe('svc_op');
});
});
describe('CloudflareMetricsRepository', () => {
class Recorder implements IMetricsProviderRepository {
public readonly pushed: Metric[] = [];
pushMetric(metric: Metric) {
this.pushed.push(metric);
}
flush() {
/* noop */
}
}
it('prefixes `push()` metrics with the operation prefix and merges default tags', () => {
const provider = new Recorder();
const repo = new CloudflareMetricsRepository('svc', new Request('https://localhost/test'), [provider], 'test-env');
repo.push(Metric.create('event').addTag('kind', 'foo').intField('count', 1));
const [metric] = provider.pushed;
expect(metric.name).toBe('svc_event');
expect(metric.tags.get('kind')).toBe('foo');
expect(metric.tags.get('environment')).toBe('test-env');
});
it('forwards `pushRaw()` metrics untouched (no prefix, no default tags)', () => {
const provider = new Recorder();
const repo = new CloudflareMetricsRepository('svc', new Request('https://localhost/test'), [provider], 'test-env');
repo.pushRaw(Metric.create('cf_event').addTag('kind', 'foo').intField('count', 1));
const [metric] = provider.pushed;
expect(metric.name).toBe('cf_event');
expect(metric.tags.get('environment')).toBeUndefined();
});
});
describe('InfluxMetricsProvider flush self-telemetry', () => {
beforeEach(() => {
__resetFlushStateForTests();
});
it('takeLastFlushStats returns null before any flush has completed', () => {
expect(takeLastFlushStats()).toBeNull();
});
it('records stats with status=ok after a successful flush', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (() => Promise.resolve(new Response('', { status: 204 }))) as typeof fetch;
try {
const provider = new InfluxMetricsProvider('token', 'prod');
provider.pushMetric(Metric.create('foo').intField('v', 1));
await provider.flush();
const stats = takeLastFlushStats();
expect(stats?.status).toBe('ok');
expect(stats?.bytes).toBeGreaterThan(0);
expect(stats?.pendingBuffers).toBe(0);
expect(stats?.pendingBytes).toBe(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it('records stats with status=error after a failed flush', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (() => Promise.resolve(new Response('bad', { status: 502 }))) as typeof fetch;
try {
const provider = new InfluxMetricsProvider('token', 'prod');
provider.pushMetric(Metric.create('foo').intField('v', 1));
await provider.flush();
const stats = takeLastFlushStats();
expect(stats?.status).toBe('error');
// The failed body gets stashed in the retry buffer — surface that
// so the next-tick self-telemetry can show it.
expect(stats?.pendingBuffers).toBe(1);
expect(stats?.pendingBytes).toBeGreaterThan(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it('clears lastFlushStats after a single take', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (() => Promise.resolve(new Response('', { status: 204 }))) as typeof fetch;
try {
const provider = new InfluxMetricsProvider('token', 'prod');
provider.pushMetric(Metric.create('foo').intField('v', 1));
await provider.flush();
expect(takeLastFlushStats()).not.toBeNull();
expect(takeLastFlushStats()).toBeNull();
} finally {
globalThis.fetch = originalFetch;
}
});
it('evicts the oldest stashed flush body when the total exceeds the 10 MB cap', async () => {
const originalFetch = globalThis.fetch;
// Always fail so every flush stashes its body in the retry buffer.
globalThis.fetch = (() => Promise.resolve(new Response('', { status: 502 }))) as typeof fetch;
try {
// Each payload is ~4 MB of raw line protocol; three of them land us
// over the 10 MB cap and force an eviction.
const padding = 'x'.repeat(4 * 1024 * 1024);
const provider = new InfluxMetricsProvider('token', 'prod');
provider.pushMetric(Metric.create('first').addTag('pad', padding).intField('v', 1));
await provider.flush();
provider.pushMetric(Metric.create('second').addTag('pad', padding).intField('v', 2));
await provider.flush();
provider.pushMetric(Metric.create('third').addTag('pad', padding).intField('v', 3));
await provider.flush();
const stats = takeLastFlushStats();
// Two buffers should remain — the oldest got dropped to stay under the cap.
expect(stats?.pendingBuffers).toBe(2);
expect(stats?.pendingBytes).toBeLessThan(10 * 1024 * 1024 + 4 * 1024 * 1024);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('InfluxMetricsProvider line protocol', () => {
beforeEach(() => {
__resetFlushStateForTests();
});
it('emits int and float fields with the correct suffixes', () => {
const provider = new InfluxMetricsProvider('', '');
const metric = Metric.create('cf_test')
.addTag('script_name', 'hello')
.intField('requests', 3)
.floatField('duration_ms', 1.5)
.setExportTimestamp(new Date('2026-04-10T12:00:00Z'));
provider.pushMetric(metric);
// Access the private `metrics` via casting — we just want to assert the line protocol.
const lines = (provider as unknown as { metrics: string[] }).metrics;
expect(lines).toHaveLength(1);
// Integer field should end with `i`, float should not.
expect(lines[0]).toMatch(/cf_test,script_name=hello .*requests=3i/);
expect(lines[0]).toMatch(/duration_ms=1\.5/);
// Timestamp in nanoseconds for 2026-04-10T12:00:00Z
const expectedNs = new Date('2026-04-10T12:00:00Z').getTime() * 1_000_000;
expect(lines[0]).toMatch(new RegExp(` ${expectedNs}$`));
});
it('flush clears the pending buffer', async () => {
const provider = new InfluxMetricsProvider('', '');
provider.pushMetric(Metric.create('foo').intField('v', 1));
expect(provider.pendingCount).toBe(1);
await provider.flush();
expect(provider.pendingCount).toBe(0);
});
it('stashes a failed flush body and resends it on the next successful flush', async () => {
// Use a fetch mock via globalThis so the provider picks it up through its
// real `fetch` call path.
const originalFetch = globalThis.fetch;
let call = 0;
const received: string[] = [];
globalThis.fetch = ((_url: RequestInfo | URL, init?: RequestInit) => {
call++;
received.push(init?.body as string);
if (call === 1) {
// First flush: backend is down.
return Promise.resolve(new Response('bad gateway', { status: 502 }));
}
return Promise.resolve(new Response('', { status: 204 }));
}) as typeof fetch;
try {
const provider = new InfluxMetricsProvider('token', 'prod');
provider.pushMetric(Metric.create('first').intField('v', 1));
await provider.flush();
// First attempt failed; the body should be stashed in the module-level
// retry buffer.
provider.pushMetric(Metric.create('second').intField('v', 2));
await provider.flush();
expect(call).toBe(2);
// Second request body should contain BOTH the retried first flush and
// the newly pushed metric, concatenated.
expect(received[1]).toContain('first');
expect(received[1]).toContain('second');
} finally {
globalThis.fetch = originalFetch;
}
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { IMetricsProviderRepository } from './metric-providers.js';
import { Metric } from './metric.js';
import { type AsyncFn, type MonitorOptions, type Operation, monitorAsyncFunction } from './monitor.js';
export interface IMetricsRepository {
monitorAsyncFunction<T extends AsyncFn>(
operation: Operation,
call: T,
options?: MonitorOptions,
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>>;
push(metric: Metric): void;
}
export class CloudflareMetricsRepository implements IMetricsRepository {
private readonly defaultTags: Record<string, string>;
constructor(
private operationPrefix: string,
request: Request,
private metricsProviders: IMetricsProviderRepository[],
environment?: string,
) {
const cf = request.cf as IncomingRequestCfProperties | undefined;
this.defaultTags = {
environment: environment ?? '',
continent: cf?.continent ?? '',
colo: cf?.colo ?? '',
asOrg: cf?.asOrganization ?? '',
};
}
monitorAsyncFunction<T extends AsyncFn>(
operation: Operation,
call: T,
options: MonitorOptions = {},
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>> {
operation = { ...operation, tags: { ...operation.tags, ...this.defaultTags } };
return monitorAsyncFunction(
this.operationPrefix,
operation,
call,
(metric) => {
for (const provider of this.metricsProviders) {
provider.pushMetric(metric);
}
},
options,
);
}
push(metric: Metric) {
metric.prefixName(this.operationPrefix);
metric.addTags(this.defaultTags);
for (const provider of this.metricsProviders) {
provider.pushMetric(metric);
}
}
// Push without prefixing or merging default tags — for Cloudflare analytics
// data where measurement names and tags come from the upstream dataset.
pushRaw(metric: Metric) {
for (const provider of this.metricsProviders) {
provider.pushMetric(metric);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { Metric } from './metric.js';
export type AsyncFn = (...args: any[]) => Promise<any>;
export type Class = { new (...args: any[]): any };
export type Operation = { name: string; tags?: Record<string, string> };
export type MonitorOptions = { monitorInvocations?: boolean; acceptedErrors?: Class[] };
export function monitorAsyncFunction<T extends AsyncFn>(
operationPrefix: string,
operation: Operation,
call: T,
metricsWriteCallback: (metric: Metric) => void,
options: MonitorOptions = {},
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>> {
const { name: operationName, tags = {} } = operation;
const { monitorInvocations = true, acceptedErrors = [] } = options;
return async (...args: Parameters<T>) => {
const metric = Metric.create(`${operationPrefix}_${operationName}`);
metric.addTags(tags);
if (monitorInvocations) {
metric.intField('invocation', 1);
}
try {
return await call(...args);
} catch (error) {
if (!acceptedErrors.some((acceptedError) => error instanceof acceptedError)) {
console.error(error, `${operationName}_errors`);
metric.intField('errors', 1);
}
throw error;
} finally {
metric.durationField('duration');
metricsWriteCallback(metric);
}
};
}
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __resetResourceCachesForTests, ResourceCacheService } from './resource-cache.js';
import { buildMetricsRepo, FakeRestClient, RecordingProvider, ThrowingRestClient } from './test-helpers.js';
const now = () => new Date('2026-04-10T12:00:00Z');
const laterNow = () => new Date('2026-04-10T12:30:00Z');
describe('ResourceCacheService', () => {
let provider: RecordingProvider;
let metrics: ReturnType<typeof buildMetricsRepo>;
beforeEach(() => {
provider = new RecordingProvider();
metrics = buildMetricsRepo(provider);
__resetResourceCachesForTests();
});
it('populates D1, queue, and zone caches from the rest client', async () => {
const restClient = new FakeRestClient(
[{ uuid: 'db-1', name: 'my-db' }],
[{ queue_id: 'q-1', queue_name: 'my-queue' }],
[{ id: 'zone-1', name: 'example.com' }],
);
const service = new ResourceCacheService('acct', metrics, now, restClient);
await service.populate();
const cache = service.getCache();
expect(cache.d1Databases.get('db-1')).toBe('my-db');
expect(cache.queues.get('q-1')).toBe('my-queue');
expect(cache.zones.get('zone-1')).toBe('example.com');
expect(cache.bulkZoneTags.has('zone-1')).toBe(true);
});
it('returns empty cache when no rest client is provided', async () => {
const service = new ResourceCacheService('acct', metrics, now);
await service.populate();
const cache = service.getCache();
expect(cache.d1Databases.size).toBe(0);
expect(cache.queues.size).toBe(0);
expect(cache.zones.size).toBe(0);
});
it('uses cached results on subsequent calls within TTL', async () => {
let callCount = 0;
const restClient = new FakeRestClient([{ uuid: 'db-1', name: 'my-db' }], [], []);
const origList = restClient.listD1Databases.bind(restClient);
restClient.listD1Databases = async (...args: Parameters<typeof restClient.listD1Databases>) => {
callCount++;
return origList(...args);
};
const service = new ResourceCacheService('acct', metrics, now, restClient);
await service.populate();
await service.populate();
expect(callCount).toBe(1);
});
it('falls back to stale global cache when rest client throws', async () => {
const restClient = new FakeRestClient(
[{ uuid: 'db-1', name: 'my-db' }],
[{ queue_id: 'q-1', queue_name: 'my-queue' }],
[],
);
const service1 = new ResourceCacheService('acct', metrics, now, restClient);
await service1.populate();
// Reset per-call caches so next populate will re-fetch, then use a throwing client
__resetResourceCachesForTests();
// Re-seed the global caches by populating once more with good data
const service1b = new ResourceCacheService('acct', metrics, now, restClient);
await service1b.populate();
// Now advance past TTL and use throwing client
const throwingClient = new ThrowingRestClient();
const service2 = new ResourceCacheService('acct', metrics, laterNow, throwingClient);
await service2.populate();
const cache = service2.getCache();
expect(cache.d1Databases.get('db-1')).toBe('my-db');
expect(cache.queues.get('q-1')).toBe('my-queue');
});
it('emits resource_lookup metrics for each resource type', async () => {
const restClient = new FakeRestClient([{ uuid: 'db-1', name: 'my-db' }], [], [{ id: 'z-1', name: 'example.com' }]);
const service = new ResourceCacheService('acct', metrics, now, restClient);
await service.populate();
const lookups = provider.metrics.filter((m) => m.name === 'cloudflare_metrics_resource_lookup');
expect(lookups).toHaveLength(3);
const resources = new Set(lookups.map((m) => m.tags.get('resource')));
expect(resources).toEqual(new Set(['d1_databases', 'queues', 'zones']));
});
it('emits error metrics when rest client throws', async () => {
const throwingClient = new ThrowingRestClient();
const service = new ResourceCacheService('acct', metrics, now, throwingClient);
await service.populate();
const lookups = provider.metrics.filter((m) => m.name === 'cloudflare_metrics_resource_lookup');
for (const lookup of lookups) {
expect(lookup.tags.get('status')).toBe('error');
}
});
describe('resolveMissingZones', () => {
it('resolves zones not in the cache via individual lookups', async () => {
const restClient = new FakeRestClient([], [], [], {
'zone-new': { id: 'zone-new', name: 'new.example.com' },
});
const service = new ResourceCacheService('acct', metrics, now, restClient);
await service.populate();
await service.resolveMissingZones(['zone-new']);
expect(service.getCache().zones.get('zone-new')).toBe('new.example.com');
});
it('skips zones already in cache', async () => {
const restClient = new FakeRestClient([], [], [{ id: 'zone-1', name: 'example.com' }]);
const service = new ResourceCacheService('acct', metrics, now, restClient);
await service.populate();
await service.resolveMissingZones(['zone-1']);
expect(restClient.getZoneCalls).toHaveLength(0);
});
it('caps individual lookups per run', async () => {
const zones: Record<string, { id: string; name: string }> = {};
for (let i = 0; i < 25; i++) {
zones[`z-${i}`] = { id: `z-${i}`, name: `zone${i}.com` };
}
const restClient = new FakeRestClient([], [], [], zones);
const service = new ResourceCacheService('acct', metrics, now, restClient);
await service.populate();
await service.resolveMissingZones(Object.keys(zones));
expect(restClient.getZoneCalls).toHaveLength(20);
});
it('does nothing when no rest client is provided', async () => {
const service = new ResourceCacheService('acct', metrics, now);
await service.populate();
await service.resolveMissingZones(['zone-1']);
expect(service.getCache().zones.size).toBe(0);
});
it('emits lookup metrics with partial status on failures', async () => {
const restClient = new FakeRestClient([], [], [], {
'zone-ok': { id: 'zone-ok', name: 'ok.com' },
});
const origGetZone = restClient.getZone.bind(restClient);
restClient.getZone = async (id: string) => {
if (id === 'zone-fail') {
throw new Error('network error');
}
return origGetZone(id);
};
const service = new ResourceCacheService('acct', metrics, now, restClient);
await service.populate();
await service.resolveMissingZones(['zone-ok', 'zone-fail']);
const lookup = provider.metrics.find(
(m) => m.name === 'cloudflare_metrics_resource_lookup' && m.tags.get('resource') === 'zones_individual',
);
expect(lookup?.tags.get('status')).toBe('partial');
expect(lookup?.fields.get('resolved')).toEqual({ value: 1, type: 'int' });
expect(lookup?.fields.get('failed')).toEqual({ value: 1, type: 'int' });
});
});
});
@@ -0,0 +1,218 @@
import type { ICloudflareRestClient } from './cloudflare-api.js';
import { Metric } from './metric.js';
import type { CloudflareMetricsRepository } from './metrics.js';
export interface ResourceCache {
d1Databases: Map<string, string>;
queues: Map<string, string>;
zones: Map<string, string>;
// Zones from the bulk /zones list (real account zones). Zone-scoped
// datasets only iterate this subset to avoid querying Pages project
// zones, which would blow through the Workers subrequest limit.
bulkZoneTags: Set<string>;
}
export function emptyResourceCache(): ResourceCache {
return {
d1Databases: new Map(),
queues: new Map(),
zones: new Map(),
bulkZoneTags: new Set(),
};
}
// Module-level caches survive across isolate invocations (minutes to hours),
// avoiding redundant REST lookups on every 1-minute cron tick.
const globalZoneNameCache = new Map<string, string>();
const globalD1NameCache = new Map<string, string>();
const globalQueueNameCache = new Map<string, string>();
interface CachedResourceLookup<T> {
values: T[];
loadedAt: number;
}
const RESOURCE_CACHE_TTL_MS = 10 * 60 * 1000;
let cachedD1Databases: CachedResourceLookup<{ uuid: string; name: string }> | null = null;
let cachedQueues: CachedResourceLookup<{ queue_id: string; queue_name: string }> | null = null;
let cachedBulkZones: CachedResourceLookup<{ id: string; name: string }> | null = null;
// Cap per-tick individual /zones/{id} lookups to stay under the 50
// subrequest limit; the global cache absorbs the rest across ticks.
const MAX_INDIVIDUAL_ZONE_LOOKUPS_PER_RUN = 20;
export function __resetResourceCachesForTests(): void {
globalZoneNameCache.clear();
globalD1NameCache.clear();
globalQueueNameCache.clear();
cachedD1Databases = null;
cachedQueues = null;
cachedBulkZones = null;
}
export class ResourceCacheService {
private cache: ResourceCache = emptyResourceCache();
constructor(
private readonly accountTag: string,
private readonly metrics: CloudflareMetricsRepository,
private readonly now: () => Date,
private readonly restClient?: ICloudflareRestClient,
) {}
getCache(): ResourceCache {
return this.cache;
}
async populate(): Promise<void> {
this.cache = emptyResourceCache();
for (const [tag, name] of globalZoneNameCache) {
this.cache.zones.set(tag, name);
}
if (!this.restClient) {
return;
}
const restClient = this.restClient;
const now = this.now().getTime();
const cacheFresh = <T>(cache: CachedResourceLookup<T> | null): cache is CachedResourceLookup<T> =>
cache !== null && now - cache.loadedAt < RESOURCE_CACHE_TTL_MS;
const [d1Result, queuesResult, zonesResult] = await Promise.allSettled([
cacheFresh(cachedD1Databases)
? Promise.resolve(cachedD1Databases.values)
: restClient.listD1Databases(this.accountTag).then((values) => {
cachedD1Databases = { values, loadedAt: now };
return values;
}),
cacheFresh(cachedQueues)
? Promise.resolve(cachedQueues.values)
: restClient.listQueues(this.accountTag).then((values) => {
cachedQueues = { values, loadedAt: now };
return values;
}),
cacheFresh(cachedBulkZones)
? Promise.resolve(cachedBulkZones.values)
: restClient.listZones(this.accountTag).then((values) => {
cachedBulkZones = { values, loadedAt: now };
return values;
}),
]);
this.recordResourceLookup('d1_databases', d1Result, (items) => {
for (const db of items) {
if (db.uuid && db.name) {
this.cache.d1Databases.set(db.uuid, db.name);
globalD1NameCache.set(db.uuid, db.name);
}
}
});
this.recordResourceLookup('queues', queuesResult, (items) => {
for (const q of items) {
if (q.queue_id && q.queue_name) {
this.cache.queues.set(q.queue_id, q.queue_name);
globalQueueNameCache.set(q.queue_id, q.queue_name);
}
}
});
this.recordResourceLookup('zones', zonesResult, (items) => {
for (const z of items) {
if (z.id && z.name) {
this.cache.zones.set(z.id, z.name);
this.cache.bulkZoneTags.add(z.id);
}
}
});
// Fall back to stale global caches when a REST lookup fails entirely,
// so metric tags still carry names rather than being empty.
if (d1Result.status === 'rejected') {
for (const [uuid, name] of globalD1NameCache) {
this.cache.d1Databases.set(uuid, name);
}
}
if (queuesResult.status === 'rejected') {
for (const [id, name] of globalQueueNameCache) {
this.cache.queues.set(id, name);
}
}
}
async resolveMissingZones(missingTags: Iterable<string>): Promise<void> {
if (!this.restClient) {
return;
}
const unique = new Set<string>();
for (const tag of missingTags) {
if (tag && !this.cache.zones.has(tag)) {
unique.add(tag);
}
}
if (unique.size === 0) {
return;
}
const ids = [...unique].slice(0, MAX_INDIVIDUAL_ZONE_LOOKUPS_PER_RUN);
const startedAt = performance.now();
const restClient = this.restClient;
const results = await Promise.allSettled(ids.map((id) => restClient.getZone(id)));
let resolved = 0;
let failed = 0;
for (const [i, result] of results.entries()) {
if (result.status === 'fulfilled' && result.value) {
this.cache.zones.set(ids[i], result.value.name);
globalZoneNameCache.set(ids[i], result.value.name);
resolved++;
} else if (result.status === 'rejected') {
failed++;
}
}
this.metrics.push(
Metric.create('resource_lookup')
.addTag('resource', 'zones_individual')
.addTag('status', failed > 0 ? 'partial' : 'success')
.intField('requested', ids.length)
.intField('resolved', resolved)
.intField('failed', failed)
.durationField('duration', performance.now() - startedAt),
);
}
private recordResourceLookup<T>(
resource: string,
result: PromiseSettledResult<T[]>,
onSuccess: (items: T[]) => void,
): void {
if (result.status === 'fulfilled') {
onSuccess(result.value);
this.metrics.push(
Metric.create('resource_lookup')
.addTag('resource', resource)
.addTag('status', 'success')
.intField('count', result.value.length),
);
} else {
console.error(`[collector] resource lookup ${resource} failed:`, errorMessage(result.reason));
this.metrics.push(
Metric.create('resource_lookup')
.addTag('resource', resource)
.addTag('status', 'error')
.addTag('error', errorTag(result.reason))
.intField('errors', 1),
);
}
}
}
function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function errorTag(error: unknown): string {
if (error instanceof Error) {
return error.name;
}
return 'unknown';
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
// The scheduled handler uses module-level state (isolateStartedAt, lastSuccessfulEndMs)
// and constructs real clients, making it hard to unit test directly. Instead we test
// the pure helper `computeWindowMs` which drives the backfill logic.
//
// computeWindowMs is not exported, so we replicate its logic here to verify the
// algorithm. The constants must stay in sync with scheduled.ts.
const DEFAULT_LAG_MS = 5 * 60 * 1000;
const DEFAULT_WINDOW_MS = 5 * 60 * 1000;
const MAX_BACKFILL_MS = 30 * 60 * 1000;
function computeWindowMs(isColdStart: boolean, lastSuccessfulEndMs: number | null, scheduledMs: number): number {
if (lastSuccessfulEndMs !== null) {
const gapMs = scheduledMs - DEFAULT_LAG_MS - lastSuccessfulEndMs;
if (gapMs > DEFAULT_WINDOW_MS) {
return Math.min(gapMs, MAX_BACKFILL_MS);
}
return DEFAULT_WINDOW_MS;
}
if (isColdStart) {
return 15 * 60 * 1000;
}
return DEFAULT_WINDOW_MS;
}
describe('computeWindowMs', () => {
const baseTime = new Date('2026-04-10T12:00:00Z').getTime();
it('returns default window when last end is recent', () => {
const lastEnd = baseTime - DEFAULT_LAG_MS - DEFAULT_WINDOW_MS;
expect(computeWindowMs(false, lastEnd, baseTime)).toBe(DEFAULT_WINDOW_MS);
});
it('extends window to cover a gap', () => {
const gapMinutes = 10;
const lastEnd = baseTime - DEFAULT_LAG_MS - gapMinutes * 60 * 1000;
const result = computeWindowMs(false, lastEnd, baseTime);
expect(result).toBe(gapMinutes * 60 * 1000);
});
it('caps backfill at MAX_BACKFILL_MS', () => {
const lastEnd = baseTime - DEFAULT_LAG_MS - 60 * 60 * 1000; // 1 hour gap
expect(computeWindowMs(false, lastEnd, baseTime)).toBe(MAX_BACKFILL_MS);
});
it('returns 15-minute window on cold start with no prior state', () => {
expect(computeWindowMs(true, null, baseTime)).toBe(15 * 60 * 1000);
});
it('returns default window when not cold start and no prior state', () => {
expect(computeWindowMs(false, null, baseTime)).toBe(DEFAULT_WINDOW_MS);
});
it('uses scheduledMs (not wall clock) for gap calculation', () => {
const lastEnd = baseTime - DEFAULT_LAG_MS - 8 * 60 * 1000;
// Two different scheduledMs values produce different gaps
const result1 = computeWindowMs(false, lastEnd, baseTime);
const result2 = computeWindowMs(false, lastEnd, baseTime + 2 * 60 * 1000);
expect(result1).toBe(8 * 60 * 1000);
expect(result2).toBe(10 * 60 * 1000);
});
it('returns default window when gap equals exactly DEFAULT_WINDOW_MS', () => {
const lastEnd = baseTime - DEFAULT_LAG_MS - DEFAULT_WINDOW_MS;
expect(computeWindowMs(false, lastEnd, baseTime)).toBe(DEFAULT_WINDOW_MS);
});
it('returns default window when gap is less than DEFAULT_WINDOW_MS', () => {
const lastEnd = baseTime - DEFAULT_LAG_MS - 2 * 60 * 1000;
expect(computeWindowMs(false, lastEnd, baseTime)).toBe(DEFAULT_WINDOW_MS);
});
});
+167
View File
@@ -0,0 +1,167 @@
import type { ICloudflareRestClient } from './cloudflare-api.js';
import type { CloudflareGraphQLClient } from './graphql-client.js';
import type { IMetricsProviderRepository } from './metric-providers.js';
import { Metric } from './metric.js';
import { CloudflareMetricsRepository } from './metrics.js';
import type { DatasetQuery, DatasetRow } from './types.js';
/**
* Test-only metrics provider that records every pushed metric so tests can
* assert on the emitted measurement name, tags, and fields without having to
* parse the final line protocol.
*/
export class RecordingProvider implements IMetricsProviderRepository {
readonly metrics: Metric[] = [];
flushCount = 0;
pushMetric(metric: Metric) {
this.metrics.push(metric);
}
flush() {
this.flushCount++;
}
}
/**
* Builds a metrics repository wrapping the given provider with the same
* prefix (`cloudflare_metrics`) used in the production entrypoint. Tests
* that care about the prefix get the same behaviour as real requests.
*/
export function buildMetricsRepo(provider: IMetricsProviderRepository) {
return new CloudflareMetricsRepository('cloudflare_metrics', new Request('https://localhost/test'), [provider], '');
}
/**
* Fake GraphQL client that returns pre-seeded rows for account and zone
* datasets. Tests configure it with a map of datasetKey → rows and
* optionally zone-scoped rows keyed by zoneTag.
*/
export class FakeGraphQLClient {
public calls: Array<{ dataset: string; range: { start: Date; end: Date } }> = [];
public zoneCalls: Array<{ zoneTags: string[]; dataset: string }> = [];
public batchCalls: Array<{ datasets: string[]; includeScheduled: boolean }> = [];
constructor(
public readonly rowsByDataset: Record<string, DatasetRow[]>,
public readonly zoneRowsByDataset: Record<string, Record<string, DatasetRow[]>> = {},
public readonly scheduledInvocations: Array<{
scriptName: string;
cron: string;
status: string;
datetime: string;
cpuTimeUs: number;
}> = [],
public readonly errorsByDataset: Record<string, string> = {},
) {}
// eslint-disable-next-line @typescript-eslint/require-await
async fetchAccountBatch(
_accountTag: string,
datasets: readonly DatasetQuery[],
range: { start: Date; end: Date },
options: { includeScheduledInvocations?: boolean } = {},
) {
this.batchCalls.push({
datasets: datasets.map((d) => d.key),
includeScheduled: options.includeScheduledInvocations ?? false,
});
const rows: Record<string, DatasetRow[]> = {};
const errors: Record<string, string> = {};
for (const dataset of datasets) {
this.calls.push({ dataset: dataset.key, range });
if (this.errorsByDataset[dataset.key]) {
errors[dataset.key] = this.errorsByDataset[dataset.key];
continue;
}
rows[dataset.key] = this.rowsByDataset[dataset.key] ?? [];
}
return {
rows,
errors,
scheduledInvocations: options.includeScheduledInvocations ? this.scheduledInvocations : undefined,
};
}
// eslint-disable-next-line @typescript-eslint/require-await
async fetchZoneBatch(zoneTags: readonly string[], dataset: DatasetQuery, _range: { start: Date; end: Date }) {
this.zoneCalls.push({ zoneTags: [...zoneTags], dataset: dataset.key });
const rows: Record<string, DatasetRow[]> = {};
const errors: Record<string, string> = {};
for (const zoneTag of zoneTags) {
const zoneRows = this.zoneRowsByDataset[dataset.key]?.[zoneTag];
if (zoneRows === undefined && this.errorsByDataset[`zone:${zoneTag}`]) {
errors[zoneTag] = this.errorsByDataset[`zone:${zoneTag}`];
continue;
}
rows[zoneTag] = zoneRows ?? [];
}
return { rows, errors };
}
}
/** `FakeGraphQLClient` duck-typed as a `CloudflareGraphQLClient` for DI. */
export function asGraphQLClient(fake: FakeGraphQLClient): CloudflareGraphQLClient {
return fake as unknown as CloudflareGraphQLClient;
}
/**
* Fake REST client returning pre-seeded D1/queue/zone lookups. The
* individualZones map is consulted by `getZone` so tests can pretend a
* pages-only zone exists outside the bulk list.
*/
export class FakeRestClient implements ICloudflareRestClient {
public getZoneCalls: string[] = [];
constructor(
public readonly d1: Array<{ uuid: string; name: string }> = [],
public readonly queues: Array<{ queue_id: string; queue_name: string }> = [],
public readonly zones: Array<{ id: string; name: string }> = [],
public readonly individualZones: Record<string, { id: string; name: string } | null> = {},
) {}
// eslint-disable-next-line @typescript-eslint/require-await
async listD1Databases() {
return this.d1;
}
// eslint-disable-next-line @typescript-eslint/require-await
async listQueues() {
return this.queues;
}
// eslint-disable-next-line @typescript-eslint/require-await
async listZones() {
return this.zones;
}
// eslint-disable-next-line @typescript-eslint/require-await
async getZone(zoneId: string) {
this.getZoneCalls.push(zoneId);
if (zoneId in this.individualZones) {
return this.individualZones[zoneId];
}
return null;
}
}
/**
* Rest client that always throws, used to test error paths on resource
* lookups (e.g. the collector should still emit a self-metric rather than
* crashing the whole cron tick).
*/
export class ThrowingRestClient implements ICloudflareRestClient {
// eslint-disable-next-line @typescript-eslint/require-await
async listD1Databases(): Promise<never> {
throw new Error('d1 boom');
}
// eslint-disable-next-line @typescript-eslint/require-await
async listQueues(): Promise<never> {
throw new Error('queues boom');
}
// eslint-disable-next-line @typescript-eslint/require-await
async listZones(): Promise<never> {
throw new Error('zones boom');
}
// eslint-disable-next-line @typescript-eslint/require-await
async getZone(): Promise<never> {
throw new Error('getZone boom');
}
}
+68
View File
@@ -0,0 +1,68 @@
export type FieldValue = number;
export type FieldType = 'int' | 'float';
// `['_top', 'count']` reads row.count directly; `['sum', 'requests']` reads row.sum.requests.
export type FieldSource = readonly [string, string];
export interface FieldSpec {
type: FieldType;
source: FieldSource;
scale?: number;
}
export interface TagSpec {
source: string;
as: string;
}
export type AggregationBlock = 'sum' | 'avg' | 'max' | 'min' | 'quantiles' | 'uniq';
export interface DatasetQuery {
key: string;
measurement: string;
field: string;
scope?: 'account' | 'zone';
dimensions: readonly string[];
blocks: Partial<Record<AggregationBlock, readonly string[]>>;
topLevelFields?: readonly string[];
tags: readonly TagSpec[];
fields: Record<string, FieldSpec>;
timestampDimension?: string;
limit?: number;
extraFilter?: Record<string, unknown>;
}
export interface DatasetRow {
dimensions: Record<string, string | number | null>;
sum?: Record<string, number | null>;
avg?: Record<string, number | null>;
max?: Record<string, number | null>;
min?: Record<string, number | null>;
quantiles?: Record<string, number | null>;
uniq?: Record<string, number | null>;
count?: number | null;
}
export interface GraphQLResponse<T> {
data: T | null;
errors?: Array<{
message: string;
path?: Array<string | number>;
extensions?: Record<string, unknown>;
}> | null;
}
export interface AccountQueryResult {
viewer: {
accounts: Array<Record<string, unknown>>;
};
}
export interface CollectionResult {
dataset: string;
rows: number;
points: number;
durationMs: number;
error?: string;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers", "vitest/globals"],
"baseUrl": ".",
"paths": {
"@immich-services/cloudflare-metrics/*": ["*"]
}
},
"include": ["src/**/*", "worker-configuration.d.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
exclude: ['**/node_modules/**', 'src/integration.test.ts'],
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.toml' },
miniflare: {
// Inject a fake VictoriaMetrics token so the InfluxMetricsProvider
// takes the real fetch path in tests — otherwise it short-circuits
// in its "no token" branch and we can't observe flush behaviour.
bindings: {
VMETRICS_API_TOKEN: 'test-token',
},
},
},
},
},
});
@@ -0,0 +1,18 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
include: ['src/integration.test.ts'],
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.toml' },
miniflare: {
bindings: {
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN ?? '',
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID ?? '',
},
},
},
},
},
});
+11
View File
@@ -0,0 +1,11 @@
interface Env {
ENVIRONMENT: string;
CLOUDFLARE_ACCOUNT_ID: string;
CLOUDFLARE_API_TOKEN?: string;
VMETRICS_API_TOKEN?: string;
}
declare module 'cloudflare:test' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ProvidedEnv extends Env {}
}
+15
View File
@@ -0,0 +1,15 @@
#:schema node_modules/wrangler/config-schema.json
name = "cloudflare-metrics-immich-app"
main = "src/index.ts"
compatibility_date = "2025-09-16"
compatibility_flags = ["nodejs_compat"]
[limits]
cpu_ms = 30000
[triggers]
crons = ["* * * * *"]
[vars]
ENVIRONMENT = ""
CLOUDFLARE_ACCOUNT_ID = ""
@@ -0,0 +1,600 @@
locals {
# Must match the folder_name input passed to `module.grafana` in dashboards.tf.
grafana_folder_name = var.stage != "" ? "cloudflare-metrics (${var.stage})" : "cloudflare-metrics"
# Mirrors the regex inside the shared grafana module
# (devtools//tf/shared/modules/grafana/grafana.tf): lowercase, anything
# that isn't a-z or 0-9 becomes "-".
dashboards_folder_uid = replace(lower(local.grafana_folder_name), "/[^a-z\\d]/", "-")
prometheus_datasource_uid = "36979063-5384-4eb9-8679-565a727cbc13"
# The worker's own script name as it appears in cf_workers_invocations.
# Must match the cloudflare_worker.worker.name resource.
worker_script_name = "${var.app_name}-api${local.resource_suffix}"
# All exporter-health alerts link back to panels on the exporter-health
# dashboard. The panel IDs are pinned in the 9000 range inside the
# dashboard generator (apps/cloudflare-metrics generate-dashboards.py) so
# reordering panels doesn't break the alert → panel links.
exporter_health_uid = "cf-exporter-health"
alert_panels = {
collector_liveness = 9001
cron_errors = 9002
dataset_errors = 9003
flush_errors = 9004
pending_flush = 9005
graphql_requests = 9006
graphql_err_response = 9007
}
}
resource "grafana_rule_group" "cloudflare_metrics_alerts" {
org_id = 1
name = "Cloudflare Metrics Exporter"
folder_uid = local.dashboards_folder_uid
interval_seconds = 60
disable_provenance = true
# 1) Collector stopped running entirely. `cron_summary_datasets` is pushed
# unconditionally once per successful tick in the scheduled handler, so
# any gap in the series means the handler is wedged, crashing, or
# missing its token. The threshold never trips in practice — it's just
# there to give the rule a condition; the real signal is NoData →
# Alerting.
rule {
name = "Collector Not Running"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 600
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max_over_time(cloudflare_metrics_cron_summary_datasets[10m])"
instant = true
intervalMs = 60000
legendFormat = "datasets"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [0], type = "lt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "Alerting"
exec_err_state = "Error"
for = "10m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.collector_liveness)
summary = "Cloudflare metrics collector is not running"
description = "No cloudflare_metrics_cron_summary_datasets datapoints in the last 10 minutes — the scheduled handler is wedged, crashing, or missing its Cloudflare API token."
}
labels = { severity = "1" }
is_paused = false
}
# 2) Per-tick cron exception. Any cron_error surfaces either a missing
# config or a thrown exception during collection.
rule {
name = "Collector Cron Error"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 600
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max(sum_over_time(cloudflare_metrics_cron_error_count[10m]))"
instant = true
intervalMs = 60000
legendFormat = "errors"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [0], type = "gt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "OK"
exec_err_state = "OK"
for = "1m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.cron_errors)
summary = "Cloudflare metrics collector threw an exception"
description = "cron_error_count was incremented in the last 10 minutes — collection threw or the handler booted without its Cloudflare credentials."
}
labels = { severity = "1" }
is_paused = false
}
# 3) Sustained per-dataset errors. A handful here and there are expected
# on datasets for products we don't use; this catches real breakage
# like plan changes or renamed fields.
rule {
name = "Collector Dataset Errors Sustained"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 900
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max(sum by (dataset) (sum_over_time(cloudflare_metrics_collector_dataset_errors[15m])))"
instant = true
intervalMs = 60000
legendFormat = "errors"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [10], type = "gt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "OK"
exec_err_state = "OK"
for = "10m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.dataset_errors)
summary = "Collector dataset errors sustained"
description = "A single dataset has had more than 10 errors in the last 15 minutes. Check the exporter-health dashboard to see which dataset is failing."
}
labels = { severity = "3" }
is_paused = false
}
# 4) Flush to VictoriaMetrics is failing. This isn't immediately
# data-loss — failed bodies get stashed and retried on the next tick —
# but sustained failures will eventually hit the 10 MB stash cap and
# drop points.
rule {
name = "Metrics Flush Failing"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 900
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max(sum_over_time(cloudflare_metrics_flush_errors[15m]))"
instant = true
intervalMs = 60000
legendFormat = "errors"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [3], type = "gt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "OK"
exec_err_state = "OK"
for = "10m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.flush_errors)
summary = "Metrics flush to VictoriaMetrics is failing"
description = "More than 3 flush errors in the last 15 minutes. The worker is stashing bodies for retry; the stash is capped at 10 MB before it starts dropping the oldest. Check VictoriaMetrics / vmauth health."
}
labels = { severity = "1" }
is_paused = false
}
# 5) Pending flush buffer growing. Catches cases where failures are
# briefly under the rate threshold but the backlog is still climbing.
rule {
name = "Pending Flush Buffer Growing"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 900
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max(cloudflare_metrics_flush_pending_buffers)"
instant = true
intervalMs = 60000
legendFormat = "buffers"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [3], type = "gt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "OK"
exec_err_state = "OK"
for = "10m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.pending_flush)
summary = "Pending flush buffer growing"
description = "The worker has stashed more than 3 failed flush bodies waiting for retry. Something is preventing the VM write endpoint from draining them."
}
labels = { severity = "3" }
is_paused = false
}
# 6) Subrequest budget near the Workers 50/invocation cap. We currently
# burn ~16 per tick; alert if we ever approach the ceiling so we know
# to re-chunk before hitting the limit in production.
rule {
name = "GraphQL Subrequest Budget Near Cap"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 600
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max(cloudflare_metrics_graphql_client_requests)"
instant = true
intervalMs = 60000
legendFormat = "requests"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [40], type = "gt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "OK"
exec_err_state = "OK"
for = "10m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.graphql_requests)
summary = "GraphQL subrequest count approaching Workers cap"
description = "A cron tick issued more than 40 GraphQL subrequests (Workers paid-plan cap is 50). Add more datasets into the same batched chunk or raise ACCOUNT_BATCH_CHUNK_SIZE before hitting the ceiling."
}
labels = { severity = "3" }
is_paused = false
}
# 7) GraphQL error-response rate. Our batched query shares chunks, so a
# wrong field name surfaces as every dataset in that chunk failing.
# Treat any sustained error-response rate as a deployment smell.
rule {
name = "GraphQL Error Responses Sustained"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 900
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max(sum_over_time(cloudflare_metrics_graphql_client_error_responses[15m]))"
instant = true
intervalMs = 60000
legendFormat = "error responses"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [5], type = "gt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "OK"
exec_err_state = "OK"
for = "10m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.graphql_err_response)
summary = "GraphQL error responses sustained"
description = "More than 5 GraphQL responses with errors[] in the last 15 minutes. This usually means a batched chunk contains an invalid field name; check the exporter-health dashboard."
}
labels = { severity = "3" }
is_paused = false
}
# 8) Collector worker crashing (exceededResources, OOM, etc). This queries
# the Cloudflare-reported error count for our own worker script — data
# that the collector itself writes to VictoriaMetrics from the analytics
# API. Because it's 5-minute-lagged analytics data written by whichever
# tick succeeds, it catches sustained crash loops that our self-telemetry
# alerts miss (since self-telemetry isn't emitted when the handler crashes).
rule {
name = "Collector Worker Crash Rate"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 1800
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max(sum_over_time(cf_workers_invocations_errors{script_name=\"${local.worker_script_name}\"}[30m]))"
instant = true
intervalMs = 60000
legendFormat = "errors"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [3], type = "gt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "OK"
exec_err_state = "OK"
for = "5m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.collector_liveness)
summary = "Collector worker is crashing"
description = "More than 3 cf_workers_invocations_errors for the collector's own script in the last 30 minutes. The worker is likely hitting exceededResources (CPU/memory limit) or another runtime crash. Check the isolate age panel and Cloudflare dashboard for the script."
}
labels = { severity = "1" }
is_paused = false
}
# 9) CPU time approaching the limit. With the ceiling at 30s, sustained
# p99 > 1s means the worker is doing more work than expected and could
# eventually hit exceededResources again if it keeps climbing.
rule {
name = "Collector CPU Time High"
condition = "C"
data {
ref_id = "A"
datasource_uid = local.prometheus_datasource_uid
relative_time_range {
from = 900
to = 0
}
model = jsonencode({
datasource = { type = "prometheus", uid = local.prometheus_datasource_uid }
editorMode = "code"
expr = "max(cf_workers_invocations_cpu_time_us_p99{script_name=\"${local.worker_script_name}\"})"
instant = true
intervalMs = 60000
legendFormat = "cpu_p99"
maxDataPoints = 43200
refId = "A"
})
}
data {
ref_id = "C"
datasource_uid = "__expr__"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
datasource = { type = "__expr__", uid = "__expr__" }
expression = "A"
refId = "C"
type = "threshold"
conditions = [{
evaluator = { params = [1000000], type = "gt" }
operator = { type = "and" }
query = { params = ["C"] }
reducer = { params = [], type = "last" }
type = "query"
}]
intervalMs = 1000
maxDataPoints = 43200
})
}
no_data_state = "OK"
exec_err_state = "OK"
for = "10m"
annotations = {
__dashboardUid__ = local.exporter_health_uid
__panelId__ = tostring(local.alert_panels.collector_liveness)
summary = "Collector CPU time is high"
description = "p99 CPU time for the collector worker has been above 1s for 10 minutes. The CPU limit is 30s but sustained high usage may indicate a problem. Check the workers dashboard for the script."
}
labels = { severity = "3" }
is_paused = false
}
}
@@ -0,0 +1,84 @@
# Read-only API token used by the cloudflare-metrics worker to query the
# GraphQL Analytics API and to enumerate resource metadata (D1 databases,
# queues, zones) that's needed to enrich metric tags with human-readable
# names. Scoped to this account only, read-only permissions.
#
# The permission group UUIDs are looked up dynamically via the bootstrap
# provider, which authenticates with the user-level `var.cloudflare_api_token`
# that already has permission to list/manage API tokens.
data "cloudflare_api_token_permission_groups_list" "all" {
provider = cloudflare.bootstrap
}
locals {
cloudflare_metrics_permission_group_names = [
"Account Analytics Read",
"Analytics Read",
"D1 Read",
"Queues Read",
"Zone Read",
]
# Some permission group names exist at multiple scopes (e.g. account and
# zone-level "Logs Read"), so we can't build a simple `name => id` map.
# Instead we look up each required name explicitly and take the first match.
cf_permission_group_ids = {
for name in local.cloudflare_metrics_permission_group_names :
name => [
for g in data.cloudflare_api_token_permission_groups_list.all.result : g.id if g.name == name
][0]
}
}
# Bumping this forces the analytics_read token to be destroyed and recreated
# on the next apply. Cloudflare provider v5 has a bug where
# `cloudflare_api_token.value` is only populated immediately after creation
# (see cloudflare/terraform-provider-cloudflare#5045), so we intentionally
# recreate the token whenever we need the value to be freshly available to
# downstream resources.
resource "terraform_data" "analytics_token_generation" {
input = "7"
}
resource "cloudflare_api_token" "analytics_read" {
provider = cloudflare.bootstrap
name = "cloudflare-metrics-analytics-read${local.resource_suffix}"
# Two policies: one scoped to the account (for account-level datasets and
# REST endpoints like D1 and Queues), and one scoped to all zones in the
# account (so `GET /zones/{id}` can resolve individual zone names for
# Cloudflare Pages projects that don't appear in the bulk `/zones` list).
policies = [
{
effect = "allow"
permission_groups = [
{ id = local.cf_permission_group_ids["Account Analytics Read"] },
{ id = local.cf_permission_group_ids["D1 Read"] },
{ id = local.cf_permission_group_ids["Queues Read"] },
]
resources = jsonencode({
"com.cloudflare.api.account.${var.cloudflare_account_id}" = "*"
})
},
{
effect = "allow"
permission_groups = [
{ id = local.cf_permission_group_ids["Zone Read"] },
{ id = local.cf_permission_group_ids["Analytics Read"] },
]
resources = jsonencode({
"com.cloudflare.api.account.zone.*" = "*"
})
},
]
lifecycle {
replace_triggered_by = [terraform_data.analytics_token_generation]
}
}
output "analytics_token_id" {
value = cloudflare_api_token.analytics_read.id
}
@@ -0,0 +1,15 @@
terraform {
backend "pg" {}
required_version = "~> 1.7"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5"
}
grafana = {
source = "grafana/grafana"
version = "~> 3"
}
}
}
@@ -0,0 +1,7 @@
module "grafana" {
source = "git::https://github.com/immich-app/devtools.git//tf/shared/modules/grafana?ref=main"
folder_name = local.grafana_folder_name
dashboards_path = "./dashboards"
env = var.env
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,602 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Browser Rendering",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "timeseries",
"title": "API Requests",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (endpoint) (max_over_time(cf_browser_rendering_api_requests[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 3,
"type": "timeseries",
"title": "Browser Time",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "ms",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_browser_rendering_browser_time_total_session_duration_ms[1m])",
"instant": false,
"range": true,
"legendFormat": "total ms/s",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 4,
"type": "timeseries",
"title": "Sessions (unique)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 9
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_browser_rendering_binding_sessions_unique_sessions[1m])",
"instant": false,
"range": true,
"legendFormat": "unique",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 5,
"type": "timeseries",
"title": "Events",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 9
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_browser_rendering_events_events[1m])",
"instant": false,
"range": true,
"legendFormat": "events",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 6,
"type": "row",
"title": "Browser Isolation",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 17
},
"panels": []
},
{
"id": 7,
"type": "timeseries",
"title": "Sessions",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 18
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_browser_isolation_sessions_sessions[1m])",
"instant": false,
"range": true,
"legendFormat": "sessions",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "User Actions",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 18
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_browser_isolation_user_actions_actions[1m])",
"instant": false,
"range": true,
"legendFormat": "actions",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — Browser",
"uid": "cf-browser",
"version": 1,
"weekStart": ""
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,554 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Queries",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "timeseries",
"title": "Queries",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (zone_name) (max_over_time(cf_dns_analytics_queries{zone_name=~\"$zone_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{zone_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 3,
"type": "timeseries",
"title": "By Query Type",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (query_type) (max_over_time(cf_dns_analytics_queries{zone_name=~\"$zone_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{query_type}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 4,
"type": "timeseries",
"title": "By Response Code",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 9
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (response_code) (max_over_time(cf_dns_analytics_queries{zone_name=~\"$zone_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{response_code}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 5,
"type": "timeseries",
"title": "Cache vs Uncached",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 9
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_dns_analytics_queries_uncached{zone_name=~\"$zone_name\"}[1m])",
"instant": false,
"range": true,
"legendFormat": "uncached",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_dns_analytics_queries_stale{zone_name=~\"$zone_name\"}[1m])",
"instant": false,
"range": true,
"legendFormat": "stale",
"refId": "B"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 6,
"type": "row",
"title": "Processing Time",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 17
},
"panels": []
},
{
"id": 7,
"type": "timeseries",
"title": "Processing Time (avg)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 18
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "µs",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "avg by (zone_name) (max_over_time(cf_dns_analytics_processing_time_us_avg{zone_name=~\"$zone_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{zone_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": [
{
"name": "zone_name",
"label": "Zone",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"query": {
"query": "label_values(cf_dns_analytics_queries, zone_name)",
"refId": "StandardVariableQuery"
},
"refresh": 2,
"regex": "",
"sort": 1,
"includeAll": true,
"allValue": ".*",
"multi": true,
"hide": 0,
"definition": "label_values(cf_dns_analytics_queries, zone_name)",
"options": [],
"skipUrlSync": false,
"current": {
"selected": false,
"text": "All",
"value": "$__all"
}
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — DNS",
"uid": "cf-dns",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,579 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Routing",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "timeseries",
"title": "Messages by Action",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (action) (max_over_time(cf_email_routing_messages{zone_name=~\"$zone_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{action}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 3,
"type": "timeseries",
"title": "Messages by Status",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (status) (max_over_time(cf_email_routing_messages{zone_name=~\"$zone_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{status}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 4,
"type": "row",
"title": "Sending",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 9
},
"panels": []
},
{
"id": 5,
"type": "timeseries",
"title": "Sending Events",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 10
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (event_type) (max_over_time(cf_email_sending_messages{zone_name=~\"$zone_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{event_type}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 6,
"type": "row",
"title": "DMARC",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 18
},
"panels": []
},
{
"id": 7,
"type": "timeseries",
"title": "DMARC Messages",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 19
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (disposition) (max_over_time(cf_dmarc_reports_messages{zone_name=~\"$zone_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{disposition}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "DMARC Pass / SPF / DKIM",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 19
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_dmarc_reports_dmarc_pass{zone_name=~\"$zone_name\"}[1m])",
"instant": false,
"range": true,
"legendFormat": "dmarc pass",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_dmarc_reports_spf_pass{zone_name=~\"$zone_name\"}[1m])",
"instant": false,
"range": true,
"legendFormat": "spf pass",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_dmarc_reports_dkim_pass{zone_name=~\"$zone_name\"}[1m])",
"instant": false,
"range": true,
"legendFormat": "dkim pass",
"refId": "C"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": [
{
"name": "zone_name",
"label": "Zone",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"query": {
"query": "label_values(cf_email_routing_messages, zone_name)",
"refId": "StandardVariableQuery"
},
"refresh": 2,
"regex": "",
"sort": 1,
"includeAll": true,
"allValue": ".*",
"multi": true,
"hide": 0,
"definition": "label_values(cf_email_routing_messages, zone_name)",
"options": [],
"skipUrlSync": false,
"current": {
"selected": false,
"text": "All",
"value": "$__all"
}
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — Email",
"uid": "cf-email",
"version": 1,
"weekStart": ""
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,933 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Overview",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "stat",
"title": "Queries (24h)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(sum_over_time(max_over_time(cf_hyperdrive_queries_count{config_id=~\"$config_id\"}[1m])[24h:1m]))",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 3,
"type": "stat",
"title": "Bytes Returned (24h)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 4,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "decbytes",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(sum_over_time(max_over_time(cf_hyperdrive_queries_query_bytes{config_id=~\"$config_id\"}[1m])[24h:1m]))",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 4,
"type": "stat",
"title": "Pool Size (max)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 8,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max(cf_hyperdrive_pool_current_pool_size{config_id=~\"$config_id\"})",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 5,
"type": "stat",
"title": "Waiting Clients",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max(cf_hyperdrive_pool_waiting_clients{config_id=~\"$config_id\"})",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 6,
"type": "row",
"title": "Queries",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 5
},
"panels": []
},
{
"id": 7,
"type": "timeseries",
"title": "Queries",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 6
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (config_id) (max_over_time(cf_hyperdrive_queries_count{config_id=~\"$config_id\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{config_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "Bytes Returned",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 6
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (config_id) (max_over_time(cf_hyperdrive_queries_query_bytes{config_id=~\"$config_id\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{config_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 9,
"type": "row",
"title": "Latency (avg per tick)",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 14
},
"panels": []
},
{
"id": 10,
"type": "timeseries",
"title": "Query Latency (avg µs)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 15
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "µs",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (config_id) (max_over_time(cf_hyperdrive_queries_query_latency_us{config_id=~\"$config_id\"}[1m])) / clamp_min(sum by (config_id) (max_over_time(cf_hyperdrive_queries_count{config_id=~\"$config_id\"}[1m])), 1)",
"instant": false,
"range": true,
"legendFormat": "{{config_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 11,
"type": "timeseries",
"title": "Connection Latency (avg µs)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 15
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "µs",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (config_id) (max_over_time(cf_hyperdrive_queries_connection_latency_us{config_id=~\"$config_id\"}[1m])) / clamp_min(sum by (config_id) (max_over_time(cf_hyperdrive_queries_count{config_id=~\"$config_id\"}[1m])), 1)",
"instant": false,
"range": true,
"legendFormat": "{{config_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 12,
"type": "row",
"title": "Pool",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 23
},
"panels": []
},
{
"id": 13,
"type": "timeseries",
"title": "Pool Size",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 24
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max by (config_id) (max_over_time(cf_hyperdrive_pool_current_pool_size{config_id=~\"$config_id\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{config_id}} current",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max by (config_id) (max_over_time(cf_hyperdrive_pool_max_pool_size{config_id=~\"$config_id\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{config_id}} max",
"refId": "B"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 14,
"type": "timeseries",
"title": "Waiting Clients",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 24
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max by (config_id) (max_over_time(cf_hyperdrive_pool_waiting_clients{config_id=~\"$config_id\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{config_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": [
{
"name": "config_id",
"label": "Config",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"query": {
"query": "label_values(cf_hyperdrive_queries_count, config_id)",
"refId": "StandardVariableQuery"
},
"refresh": 2,
"regex": "",
"sort": 1,
"includeAll": true,
"allValue": ".*",
"multi": true,
"hide": 0,
"definition": "label_values(cf_hyperdrive_queries_count, config_id)",
"options": [],
"skipUrlSync": false,
"current": {
"selected": false,
"text": "All",
"value": "$__all"
}
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — Hyperdrive",
"uid": "cf-hyperdrive",
"version": 1,
"weekStart": ""
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,730 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Overview",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "stat",
"title": "Requests (24h)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(sum_over_time(max_over_time(cf_pages_functions_invocations_requests{script_name=~\"$script_name\"}[1m])[24h:1m]))",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 3,
"type": "stat",
"title": "Errors (24h)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 4,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(sum_over_time(max_over_time(cf_pages_functions_invocations_errors{script_name=~\"$script_name\"}[1m])[24h:1m]))",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 4,
"type": "stat",
"title": "CPU p99",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 8,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "µs",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max(cf_pages_functions_invocations_cpu_time_us_p99{script_name=~\"$script_name\"})",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 5,
"type": "stat",
"title": "Duration p99",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "ms",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max(cf_pages_functions_invocations_duration_ms_p99{script_name=~\"$script_name\"})",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 6,
"type": "row",
"title": "Traffic",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 5
},
"panels": []
},
{
"id": 7,
"type": "timeseries",
"title": "Requests per Script",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 6
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (script_name) (max_over_time(cf_pages_functions_invocations_requests{script_name=~\"$script_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{script_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "Errors per Script",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 6
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": false,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (script_name) (max_over_time(cf_pages_functions_invocations_errors{script_name=~\"$script_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{script_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 9,
"type": "row",
"title": "Performance",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 14
},
"panels": []
},
{
"id": 10,
"type": "timeseries",
"title": "CPU p99",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 15
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "µs",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max by (script_name) (max_over_time(cf_pages_functions_invocations_cpu_time_us_p99{script_name=~\"$script_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{script_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 11,
"type": "timeseries",
"title": "Duration p99",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 15
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "ms",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max by (script_name) (max_over_time(cf_pages_functions_invocations_duration_ms_p99{script_name=~\"$script_name\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{script_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": [
{
"name": "script_name",
"label": "Script",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"query": {
"query": "label_values(cf_pages_functions_invocations_requests, script_name)",
"refId": "StandardVariableQuery"
},
"refresh": 2,
"regex": "",
"sort": 1,
"includeAll": true,
"allValue": ".*",
"multi": true,
"hide": 0,
"definition": "label_values(cf_pages_functions_invocations_requests, script_name)",
"options": [],
"skipUrlSync": false,
"current": {
"selected": false,
"text": "All",
"value": "$__all"
}
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — Pages Functions",
"uid": "cf-pages-functions",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,424 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Ingestion",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "timeseries",
"title": "Ingested Bytes",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (pipeline_id) (max_over_time(cf_pipelines_ingestion_ingested_bytes[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{pipeline_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 3,
"type": "timeseries",
"title": "Delivered Bytes",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (pipeline_id) (max_over_time(cf_pipelines_delivery_delivered_bytes[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{pipeline_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 4,
"type": "row",
"title": "Operators / Sinks",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 9
},
"panels": []
},
{
"id": 5,
"type": "timeseries",
"title": "Operator Bytes In",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 10
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (pipeline_id) (max_over_time(cf_pipelines_operator_bytes_in[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{pipeline_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 6,
"type": "timeseries",
"title": "Sink Bytes Written",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 10
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (pipeline_id) (max_over_time(cf_pipelines_sink_bytes_written[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{pipeline_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — Pipelines",
"uid": "cf-pipelines",
"version": 1,
"weekStart": ""
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,335 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "RUM Pageloads",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "timeseries",
"title": "Pageloads",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (host_name) (max_over_time(cf_rum_pageload_visits[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{host_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 3,
"type": "timeseries",
"title": "Performance Events",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (host_name) (max_over_time(cf_rum_performance_visits[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{host_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 4,
"type": "row",
"title": "Web Vitals",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 9
},
"panels": []
},
{
"id": 5,
"type": "timeseries",
"title": "Web Vitals Visits",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 10
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (host_name) (max_over_time(cf_rum_web_vitals_visits[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{host_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — RUM",
"uid": "cf-rum",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,882 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Stream",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "timeseries",
"title": "Minutes Viewed",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (video_id) (max_over_time(cf_stream_minutes_viewed_minutes_viewed[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{video_id}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 3,
"type": "timeseries",
"title": "CMCD (ms viewed)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_stream_cmcd_milliseconds_viewed[1m])",
"instant": false,
"range": true,
"legendFormat": "ms/s",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 4,
"type": "row",
"title": "Video Events",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 9
},
"panels": []
},
{
"id": 5,
"type": "timeseries",
"title": "Buffer Events",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 10
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_video_buffer_events_events[1m])",
"instant": false,
"range": true,
"legendFormat": "events",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 6,
"type": "timeseries",
"title": "Playback Events (time viewed)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 10
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_video_playback_events_time_viewed_minutes[1m])",
"instant": false,
"range": true,
"legendFormat": "minutes/s",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 7,
"type": "timeseries",
"title": "Quality Events",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 18
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_video_quality_events_events[1m])",
"instant": false,
"range": true,
"legendFormat": "events",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "Live Input Events",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 18
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_live_input_events_events[1m])",
"instant": false,
"range": true,
"legendFormat": "events",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 9,
"type": "row",
"title": "Realtime & Calls",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 26
},
"panels": []
},
{
"id": 10,
"type": "timeseries",
"title": "Realtime Kit Media Minutes",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 27
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_realtime_kit_usage_media_minutes[1m])",
"instant": false,
"range": true,
"legendFormat": "minutes/s",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 11,
"type": "timeseries",
"title": "Calls Egress",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 27
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_calls_usage_egress_bytes[1m])",
"instant": false,
"range": true,
"legendFormat": "bytes/s",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 12,
"type": "timeseries",
"title": "Calls TURN Egress",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 35
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum (max_over_time(cf_calls_turn_usage_egress_bytes[1m])",
"instant": false,
"range": true,
"legendFormat": "bytes/s",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — Stream / Video / Calls",
"uid": "cf-stream",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,691 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Operations",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "timeseries",
"title": "Operations per Index",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (index_name) (max_over_time(cf_vectorize_operations_operations[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{index_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 3,
"type": "timeseries",
"title": "Queries",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (index_name) (max_over_time(cf_vectorize_queries_queries[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{index_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 4,
"type": "row",
"title": "Storage / Writes",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 9
},
"panels": []
},
{
"id": 5,
"type": "timeseries",
"title": "Stored Vectors",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 10
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "last_over_time((max by (index_name) (cf_vectorize_storage_vector_count))[6h:])",
"instant": false,
"range": true,
"legendFormat": "{{index_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 6,
"type": "timeseries",
"title": "Vectors Written",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 10
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (index_name) (max_over_time(cf_vectorize_writes_added_vectors[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{index_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 7,
"type": "row",
"title": "Cost Estimate",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 18
},
"panels": []
},
{
"id": 8,
"type": "stat",
"title": "Estimated Daily Cost",
"description": "Before free tier.\nFree tier: 50M queried dims + 10M stored dims/mo included",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 19
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 4,
"mappings": [],
"noValue": "$0.00",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 0.1
},
{
"color": "orange",
"value": 1
},
{
"color": "red",
"value": 10
}
]
},
"unit": "currencyUSD"
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "(sum(sum_over_time(max_over_time(cf_vectorize_queries_queried_vector_dimensions[1m])[24h:1m])) / 1e6 * 0.01) + (last_over_time((sum(cf_vectorize_storage_stored_vector_dimensions))[6h:]) / 1e8 * 0.05 / 30)",
"instant": true,
"range": false,
"refId": "A"
}
]
},
{
"id": 9,
"type": "stat",
"title": "Projected Monthly Cost",
"description": "30-day projection based on last 24h usage.\nFree tier: 50M queried dims + 10M stored dims/mo included",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 19
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 4,
"mappings": [],
"noValue": "$0.00",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 0.1
},
{
"color": "orange",
"value": 1
},
{
"color": "red",
"value": 10
}
]
},
"unit": "currencyUSD"
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "((sum(sum_over_time(max_over_time(cf_vectorize_queries_queried_vector_dimensions[1m])[24h:1m])) / 1e6 * 0.01) + (last_over_time((sum(cf_vectorize_storage_stored_vector_dimensions))[6h:]) / 1e8 * 0.05 / 30)) * 30",
"instant": true,
"range": false,
"refId": "A"
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "Hourly Cost Breakdown",
"description": "Cost per hour by component, stacked.\nFree tier: 50M queried dims + 10M stored dims/mo included",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 19
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 20,
"gradientMode": "none",
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "normal"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "currencyUSD"
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"sum",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(sum_over_time(max_over_time(cf_vectorize_queries_queried_vector_dimensions[1m])[1h:1m])) / 1e6 * 0.01",
"legendFormat": "Queried Dims",
"refId": "A",
"instant": false,
"range": true
},
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "last_over_time((sum(cf_vectorize_storage_stored_vector_dimensions))[6h:]) / 1e8 * 0.05 / 30",
"legendFormat": "Stored Dims (daily)",
"refId": "B",
"instant": false,
"range": true
}
]
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — Vectorize",
"uid": "cf-vectorize",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,758 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"id": 1,
"type": "row",
"title": "Overview",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"panels": []
},
{
"id": 2,
"type": "stat",
"title": "Invocations (24h)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 0,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(sum_over_time(max_over_time(cf_workers_scheduled_invocations{script_name=~\"$script_name\",cron=~\"$cron\"}[1m])[24h:1m]))",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 3,
"type": "stat",
"title": "Errors (24h)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 4,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(sum_over_time(max_over_time(cf_workers_scheduled_invocations{script_name=~\"$script_name\",cron=~\"$cron\",status=\"error\"}[1m])[24h:1m]))",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 4,
"type": "stat",
"title": "Error Rate",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 8,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 2,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "percentunit",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(cf_workers_scheduled_invocations{script_name=~\"$script_name\",cron=~\"$cron\",status=\"error\"}) / clamp_min(sum(cf_workers_scheduled_invocations{script_name=~\"$script_name\",cron=~\"$cron\"}), 1)",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 5,
"type": "stat",
"title": "CPU Sum (24h)",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 12,
"y": 1
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "µs",
"noValue": "0"
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum(sum_over_time(max_over_time(cf_workers_scheduled_cpu_time_us_sum{script_name=~\"$script_name\",cron=~\"$cron\"}[1m])[24h:1m]))",
"instant": true,
"range": false,
"legendFormat": "__auto",
"refId": "A"
}
],
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
}
},
{
"id": 6,
"type": "row",
"title": "Rates",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 5
},
"panels": []
},
{
"id": 7,
"type": "timeseries",
"title": "Invocations per Script",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 6
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (script_name, status) (max_over_time(cf_workers_scheduled_invocations{script_name=~\"$script_name\",cron=~\"$cron\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{script_name}} {{status}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "Errors per Script",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 6
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": false,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (script_name) (max_over_time(cf_workers_scheduled_invocations{script_name=~\"$script_name\",cron=~\"$cron\",status=\"error\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{script_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 9,
"type": "row",
"title": "CPU",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 14
},
"panels": []
},
{
"id": 10,
"type": "timeseries",
"title": "CPU Time Sum",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 15
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "µs",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "sum by (script_name) (max_over_time(cf_workers_scheduled_cpu_time_us_sum{script_name=~\"$script_name\",cron=~\"$cron\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{script_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 11,
"type": "timeseries",
"title": "CPU Time Max",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 15
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "µs",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"lineWidth": 1,
"fillOpacity": 10,
"gradientMode": "none",
"spanNulls": true,
"showPoints": "never",
"pointSize": 5,
"stacking": {
"mode": "none",
"group": "A"
},
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"editorMode": "code",
"expr": "max by (script_name) (max_over_time(cf_workers_scheduled_cpu_time_us_max{script_name=~\"$script_name\",cron=~\"$cron\"}[1m]))",
"instant": false,
"range": true,
"legendFormat": "{{script_name}}",
"refId": "A"
}
],
"options": {
"legend": {
"calcs": [
"mean",
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"tags": [
"cloudflare-workers",
"cloudflare-metrics",
"cloudflare-analytics"
],
"templating": {
"list": [
{
"name": "script_name",
"label": "Script",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"query": {
"query": "label_values(cf_workers_scheduled_invocations, script_name)",
"refId": "StandardVariableQuery"
},
"refresh": 2,
"regex": "",
"sort": 1,
"includeAll": true,
"allValue": ".*",
"multi": true,
"hide": 0,
"definition": "label_values(cf_workers_scheduled_invocations, script_name)",
"options": [],
"skipUrlSync": false,
"current": {
"selected": false,
"text": "All",
"value": "$__all"
}
},
{
"name": "cron",
"label": "Cron",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "36979063-5384-4eb9-8679-565a727cbc13"
},
"query": {
"query": "label_values(cf_workers_scheduled_invocations, cron)",
"refId": "StandardVariableQuery"
},
"refresh": 2,
"regex": "",
"sort": 1,
"includeAll": true,
"allValue": ".*",
"multi": true,
"hide": 0,
"definition": "label_values(cf_workers_scheduled_invocations, cron)",
"options": [],
"skipUrlSync": false,
"current": {
"selected": false,
"text": "All",
"value": "$__all"
}
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Cloudflare — Workers Scheduled",
"uid": "cf-workers-scheduled",
"version": 1,
"weekStart": ""
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
locals {
resource_stage = var.stage != "" ? "-${var.stage}" : ""
resource_env = "-${var.env}"
resource_suffix = "${local.resource_env}${local.resource_stage}"
}
@@ -0,0 +1,17 @@
provider "cloudflare" {
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account
}
# Separate provider using the bootstrap token (same one the devtools api-keys
# module uses) because the account-scoped token above does not hold the
# "User API Tokens Write" permission required to call `POST /user/tokens`.
# Only the analytics token resource uses this provider.
provider "cloudflare" {
alias = "bootstrap"
api_token = var.cloudflare_api_token
}
provider "grafana" {
url = var.grafana_url
auth = var.grafana_token
}
@@ -0,0 +1,22 @@
variable "tf_state_postgres_conn_str" {
description = "PostgreSQL connection string for Terraform state"
type = string
}
data "terraform_remote_state" "api_keys_state" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_api_keys"
}
}
data "terraform_remote_state" "cloudflare_account" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_account"
}
}
@@ -0,0 +1,30 @@
terraform {
source = "."
extra_arguments custom_vars {
commands = get_terraform_commands_that_need_vars()
}
}
include {
path = find_in_parent_folders("state.hcl")
}
locals {
env = get_env("TF_VAR_env")
stage = get_env("TF_VAR_stage")
app_name = "cloudflare-metrics"
}
inputs = {
app_name = local.app_name
}
remote_state {
backend = "pg"
config = {
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
schema_name = "services_cf_workers_${local.app_name}_${local.env}${local.stage}"
}
}
@@ -0,0 +1,28 @@
variable "stage" {}
variable "env" {}
variable "app_name" {}
variable "cloudflare_account_id" {}
variable "dist_dir" {}
variable "vmetrics_api_token" {
description = "VMetrics API token used by the worker to push metrics to VictoriaMetrics"
type = string
sensitive = true
}
variable "cloudflare_api_token" {
description = "Bootstrap Cloudflare API token with user-level token write permissions, used only to provision the analytics-read token for this worker. Matches the token used by the devtools api-keys module."
type = string
sensitive = true
}
variable "grafana_url" {
description = "Grafana instance URL"
type = string
}
variable "grafana_token" {
description = "Grafana API authentication token"
type = string
sensitive = true
}
@@ -0,0 +1,90 @@
resource "cloudflare_worker" "worker" {
account_id = var.cloudflare_account_id
name = "${var.app_name}-api${local.resource_suffix}"
logpush = true
observability = {
enabled = true
head_sampling_rate = 1
logs = {
enabled = true
invocation_logs = true
}
}
}
resource "terraform_data" "source_hash" {
input = filesha256("${var.dist_dir}/${var.app_name}/index.js")
}
resource "cloudflare_worker_version" "worker" {
account_id = var.cloudflare_account_id
worker_id = cloudflare_worker.worker.id
usage_model = "standard"
limits = {
cpu_ms = 30000
}
bindings = [
{
name = "ENVIRONMENT"
type = "plain_text"
text = var.env
},
{
name = "CLOUDFLARE_ACCOUNT_ID"
type = "plain_text"
text = var.cloudflare_account_id
},
{
name = "CLOUDFLARE_API_TOKEN"
type = "secret_text"
text = cloudflare_api_token.analytics_read.value
},
{
name = "VMETRICS_API_TOKEN"
type = "secret_text"
text = var.vmetrics_api_token
},
]
compatibility_date = "2025-09-16"
compatibility_flags = ["nodejs_compat"]
main_module = "index.js"
modules = [
{
content_file = "${var.dist_dir}/${var.app_name}/index.js"
content_type = "application/javascript+module"
name = "index.js"
}
]
lifecycle {
replace_triggered_by = [
terraform_data.source_hash,
cloudflare_api_token.analytics_read,
]
}
}
resource "cloudflare_workers_deployment" "worker" {
account_id = var.cloudflare_account_id
script_name = cloudflare_worker.worker.name
strategy = "percentage"
versions = [
{
percentage = 100
version_id = cloudflare_worker_version.worker.id
}
]
}
resource "cloudflare_workers_cron_trigger" "collect" {
account_id = var.cloudflare_account_id
script_name = cloudflare_worker.worker.name
schedules = [{ cron = "* * * * *" }]
depends_on = [cloudflare_workers_deployment.worker]
}
# Empty preview_url output keeps the shared CI preview-URL collector happy —
# this worker has no custom domain, but the workflow still calls
# `terragrunt output -raw preview_url` for every module.
output "preview_url" {
value = ""
}
+6
View File
@@ -59,6 +59,12 @@ importers:
specifier: ^4.35.0
version: 4.35.0(@cloudflare/workers-types@4.20250909.0)
apps/cloudflare-metrics:
dependencies:
'@influxdata/influxdb-client':
specifier: ^1.34.0
version: 1.35.0
apps/github-approval-check:
dependencies:
'@octokit/app':