-
-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add cloudflare-metrics worker for graphql analytics export #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
109 commits
Select commit
Hold shift + click to select a range
8ae7070
feat: add cloudflare-metrics worker that exports graphql analytics to…
zackpollard 483c6f2
feat(cloudflare-metrics): add grafana overview dashboard
zackpollard 547c1e3
fix(cloudflare-metrics): hardcode analytics read permission group uuid
zackpollard f993bca
fix(cloudflare-metrics): inject analytics api token instead of creati…
zackpollard c22bbdc
chore(cloudflare-metrics): defer analytics token to follow-up
zackpollard 8277172
chore(cloudflare-metrics): add empty preview_url output for ci previe…
zackpollard fb0fe8f
feat(cloudflare-metrics): provision analytics token via terraform usi…
zackpollard 1f25745
fix(cloudflare-metrics): pin api token value in terraform_data to sur…
zackpollard c2ab590
fix(cloudflare-metrics): force token recreation to capture fresh valu…
zackpollard c1a57fa
fix(cloudflare-metrics): force api token replacement via generation t…
zackpollard 811d5b7
chore(cloudflare-metrics): bump token generation to trigger rotation
zackpollard a114cb3
debug(cloudflare-metrics): use token value directly and add length di…
zackpollard 71d0fbc
fix(cloudflare-metrics): mark diagnostic output as sensitive
zackpollard 7b97fe8
debug(cloudflare-metrics): expose token value length via nonsensitive…
zackpollard 69091f2
debug(cloudflare-metrics): expose account id length and env outputs
zackpollard 75d6b1d
chore: ignore local deployment/.env.* files
zackpollard 9132095
debug(cloudflare-metrics): add diagnostic subrequest beacon with bind…
zackpollard e366336
fix(cloudflare-metrics): look up fetch lazily at call time via global…
zackpollard 80c25de
chore(cloudflare-metrics): remove debug diagnostics now that pipeline…
zackpollard e9fb58a
feat(cloudflare-metrics): enrich d1/queue/zone metrics with resource …
zackpollard aea3522
fix(cloudflare-metrics): handle duplicate permission group names in l…
zackpollard 74039d2
fix(cloudflare-metrics): resolve pages zones individually via /zones/…
zackpollard 00c7c79
fix(cloudflare-metrics): grant zone read permission for per-zone lookups
zackpollard 4858b42
fix(cloudflare-metrics): use match-all wildcard for zone read resourc…
zackpollard 55a3f99
feat(cloudflare-metrics): 1-minute granularity and 4 new datasets
zackpollard 85de619
fix(cloudflare-metrics): avoid subrequest limit by caching zones and …
zackpollard 84bd8d0
feat(cloudflare-metrics): batch graphql queries to avoid subrequest l…
zackpollard 0738acb
feat(cloudflare-metrics): add dashboard panels for new datasets
zackpollard 210d356
fix(cloudflare-metrics): remove hardcoded 5m interval from dashboard …
zackpollard 84558ea
docs(cloudflare-metrics): add readme covering scope, datasets, and todos
zackpollard e4011c4
feat(cloudflare-metrics): every-minute cron, cross-invocation caches,…
zackpollard 9e9e61b
feat(cloudflare-metrics): expand account-scope dataset coverage
zackpollard 497ac30
feat(cloudflare-metrics): add zone-scope dataset coverage
zackpollard 9887753
feat(cloudflare-metrics): restructure dashboards into per-product layout
zackpollard dc34bf1
fix(cloudflare-metrics): chunk account batches under graphql node limit
zackpollard bd412f5
fix(cloudflare-metrics): align dashboards with actual emitted metrics
zackpollard f9f2651
feat(cloudflare-metrics): add grafana alerts for collector and flush …
zackpollard d5b15c9
fix(cloudflare-metrics): invert "collector not running" alert logic
zackpollard b8e61f8
fix(cloudflare-metrics): simplify "collector not running" alert
zackpollard 55d1700
feat(cloudflare-metrics): link every alert to its exporter-health panel
zackpollard 03c5d63
refactor(cloudflare-metrics): drop /collect manual trigger endpoint
zackpollard b42adce
test(cloudflare-metrics): split monolithic test file into per-module …
zackpollard 9720a0b
test(cloudflare-metrics): cover flush self-telemetry and scheduled ha…
zackpollard ac40872
ci(cloudflare-metrics): run integration tests on path-filtered PRs
zackpollard dec8c19
fix(cloudflare-metrics): align scheduled handler signature + skip int…
zackpollard e22a0fd
refactor(cloudflare-metrics): split metrics.ts by concern
zackpollard c0da950
refactor(cloudflare-metrics): split graphql query builders from trans…
zackpollard 8abe01c
refactor(cloudflare-metrics): split collector into resource-cache + e…
zackpollard 3b30d6a
refactor(cloudflare-metrics): move handlers out of index.ts
zackpollard a12f263
refactor(cloudflare-metrics): migrate call sites to canonical import …
zackpollard 34a871c
chore: bump github actions to latest + remove unnecessary comments
zackpollard 585ff7a
docs(cloudflare-metrics): rewrite readme to reflect current state
zackpollard 0002809
feat(cloudflare-metrics): emit isolate age metric to track recycling
zackpollard 61c993d
fix(cloudflare-metrics): use range query for collector liveness alert
zackpollard 9daed09
fix(cloudflare-metrics): set usage_model=standard to avoid 50ms cpu l…
zackpollard d6ae40b
fix(cloudflare-metrics): backfill gaps on cold start + alert on worke…
zackpollard b08c322
fix: use increase() instead of rate() for dataset errors graph
zackpollard 23cfdbf
fix: gap-aware backfill, raise cpu limit to 30s, add cpu time alert
zackpollard 6e7878c
feat: add error detail tags and error details table to dashboards
zackpollard 3f787d8
fix: retry graphql chunks when all fields error
zackpollard 26a73d5
fix: don't count retried error responses in error_responses metric
zackpollard 3454a9a
fix: reduce retry sleep to 250ms, add retry metrics and error logging
zackpollard 0f7adf8
fix: add zone analytics read permission to api token
zackpollard 4d56c27
fix: trigger worker version replacement on api token recreation
zackpollard 7de6e69
fix: use account analytics read for zone-scoped policy, fix formatting
zackpollard 05665fd
fix: use zone-scoped analytics read permission for zone analytics
zackpollard 19d033b
fix: remove crossZoneSubrequests field from http_requests_detail dataset
zackpollard 43656ce
fix: use date granularity for r2 storage dataset
zackpollard f59adee
fix: use date granularity for all storage/snapshot datasets
zackpollard f2816c8
chore: add debug logging for storage dataset collection
zackpollard 8744e44
fix: wrap storage metric queries with last_over_time to fill gaps
zackpollard a0d499b
feat: add estimated billing panels to all dashboards
zackpollard 54317a0
fix: only collect date-granularity datasets once per hour
zackpollard 44cf5aa
fix: limit date-granularity datasets to 100 rows with DESC order
zackpollard 03e5860
refactor: drop date granularity for storage datasets
zackpollard ca896b2
perf: reduce cpu usage in hot path
zackpollard f86a398
fix: prevent backfill death spiral
zackpollard 695f3e3
perf: direct line protocol + tighter flush buffer cap
zackpollard 4c6928e
chore: fix lint errors in line protocol escaping
zackpollard bb6dfcf
chore: format escape helpers
zackpollard 838bc74
perf: precompute per-dataset tag enricher to avoid per-row switch
zackpollard 3ba4936
chore: bump build marker to force fresh isolate
zackpollard c30bc43
fix: work around cloudflare terraform provider cpu_ms bug
zackpollard 16608c7
fix: force standard usage_model on cloudflare-metrics service-env
zackpollard 5e5f139
revert: roll back cpu-limit safety hacks now that usage_model is fixed
zackpollard dd50bc6
chore: set cpu_ms back to 30000
zackpollard 1c375a0
fix: make service-env PATCH non-fatal in deploy
zackpollard ffbae55
feat: enable workers observability on cloudflare-metrics
zackpollard 5ae515f
fix: use scheduledTime and widen window to prevent cron-miss gaps
zackpollard e5933b8
fix: subrequests-by-status panel had wrong label and rate() on gauge
zackpollard 716282f
fix: replace rate() with raw metrics on all workers dashboard panels
zackpollard ce03a34
fix: align exporter-health dashboard queries to 1-minute intervals
zackpollard df19149
fix: connect data points in workers dashboard timeseries panels
zackpollard 74b2194
fix: sweep all dashboards — remove rate(), connect data points
zackpollard 969f7ed
fix: wrap all dashboard metrics with max_over_time to handle multi-co…
zackpollard f57810e
fix: use 1m window for max_over_time (was 2m)
zackpollard 8f4f3cb
fix: replace all increase() with sum_over_time() for gauge metrics
zackpollard 66e3f37
fix: correct units on all dashboard panels
zackpollard b982cf5
fix: guard against NaN/Infinity, div-by-zero, and add missing tests
zackpollard c33675c
chore: trigger redeploy to test usage_model regression
zackpollard 380ec17
fix: set usage_model=standard on worker_version, drop unreliable post…
zackpollard 953028a
test: add cpu-test worker with no usage_model to verify default
zackpollard 1758b15
test: set usage_model=standard on cpu-test worker_version
zackpollard d65c0ca
chore: remove cpu-test scaffolding — experiment complete
zackpollard 9f7b07a
test: redeploy cpu-test + force new cloudflare-metrics version
zackpollard 1aeb1e1
test: force cloudflare-metrics redeploy to validate revert pattern
zackpollard 7ba393a
test: redeploy cloudflare-metrics after cloudflare runtime fix
zackpollard b53dfc5
fix: repair failing format, tsc, and unit test checks
zackpollard 01b5219
chore: remove cpu-test worker and FORCE_NEW_VERSION debug constant
zackpollard File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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" | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.