mirror of
https://github.com/immich-app/services.git
synced 2026-09-30 13:23:10 +08:00
feat: add cloudflare-metrics worker for graphql analytics export (#28)
This commit is contained in:
+11
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+2674
File diff suppressed because it is too large
Load Diff
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": ""
|
||||
}
|
||||
+1472
File diff suppressed because it is too large
Load Diff
@@ -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": ""
|
||||
}
|
||||
+2032
File diff suppressed because it is too large
Load Diff
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
+730
@@ -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": ""
|
||||
}
|
||||
+758
@@ -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 = ""
|
||||
}
|
||||
Generated
+6
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user