Follow up to commit 042750c which only converted workflow-level
invocations. With Node.js 22 as the floor, the chained scripts inside
each package.json can also use `node --run` directly, dropping the
intermediate npm process when these scripts run.
- root `prepare`
- browser `build-all`, `lint`
- website/client `lint`
- website/server `lint`
Now that the minimum supported Node.js version is 22, `node --run` is
available everywhere. It avoids the npm process-spawn overhead and
matches the style already used in package.json scripts.
Affects all GitHub Actions workflows that invoke npm scripts and the
website/server Dockerfile bundle step. `npm ci` is left as-is since it
is npm-specific.
Six items from claude's incremental review (`12:48:43Z`):
- monitoring/dashboard.json: Group the outcomes widget by both
`metric.label.outcome` and `metric.label.reason`. Previously all
failures collapsed into a single `turnstile_failed` series, which
contradicted the README claim that the `reason` label drives the
breakdown.
- monitoring/metrics/*.yaml: Narrow the metric filter to
`jsonPayload.event=("turnstile_siteverify" OR "pack_completed")`.
Without this anchor, any future code path attaching
`siteverifyDurationMs` to an unrelated log silently joins the
distribution and creates new metric label values.
- usePackRequest.ts: Mirror `progressMessage.value = null` alongside
the `progressStage.value = null` clear on token-acquisition aborted /
error branches. Prevents a future edit setting a verifying message
from leaking prior-run state.
- turnstile.test.ts: Add a focused `describe` block with five tests
asserting `siteverifyDurationMs` is attached to every post-siteverify
log (one success path + four reject branches). The metric YAML
filters on field presence, so a refactor that drops the field on any
branch would silently break the metric without other tests failing.
Uses the existing `vi.spyOn(logger, ...)` pattern; no clock injection
needed.
- monitoring/README.md: Note that the metric filter pins
`service_name="repomix-server-us"`, so future regions (`-eu`,
`-asia`) silently drop out until the filter is broadened or
per-region counterparts applied.
- monitoring/README.md: Add a `gcloud logging metrics describe` snippet
for verifying a YAML edit was actually applied (gcloud update is
silent on no-op vs effective change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six items from gemini, claude initial review, and claude follow-up:
- turnstile.ts: Update misleading comment that claimed the metric filters
on `event=turnstile_siteverify` and `outcome=success`. The actual
Cloud Monitoring metrics in `monitoring/metrics/` filter on
`siteverifyDurationMs` field presence, which uniformly captures both
the parallel success log (event=turnstile_siteverify) and the four
rejectAndLog failure paths (event=pack_completed). The comment
contradicted README and YAML and would mislead future readers.
- turnstile.ts: Wrap rejectAndLog in a local `rejectWithDuration` helper
so every post-siteverify branch automatically carries
`siteverifyDurationMs`. Prevents drift if a fifth reject reason gets
added later.
- client.ts: Split the wire-protocol `PackProgressStage` (server-emitted
SSE values) from the display-only `DisplayProgressStage` superset that
adds `verifying`. Keeping the synthetic stage out of the wire type
prevents silent divergence with the server's `PackProgressStage`.
- usePackRequest.ts, TryItLoading.vue, TryItResult.vue: Switch the
display-side type to `DisplayProgressStage`. `onProgress` callbacks
still take the wire `PackProgressStage`.
- usePackRequest.ts: Clear `progressStage` on token-acquisition failure
branches (aborted / error). Functionally invisible since loading=false
hides the loading UI, but prevents the next submit's verifying flash
from briefly showing the previous run's stale state.
- monitoring/metrics/turnstile_siteverify_duration.yaml: Retune the
exponential bucket layout for the 100ms-1s SLO band where decisions
get made. Doubling buckets only placed ~3 boundaries between 100ms
and 1s; growthFactor=1.5 with scale=10 places ~8 boundaries there.
18 finite buckets cover 10ms to ~9.85s, comfortably above the 5s
siteverify timeout so timeouts don't land in overflow.
- monitoring/README.md: Document that pre-network rejections
(secret_missing, missing_token, token_too_long) intentionally don't
carry siteverifyDurationMs, so they're excluded from both metrics
but still appear in the existing pack_requests metric.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the metric setup from prose-only README instructions to checked-in
YAML files under `monitoring/metrics/` so the dashboard, the metrics it
depends on, and the apply commands all live next to each other.
- `turnstile_siteverify_duration.yaml`: distribution metric on
`jsonPayload.siteverifyDurationMs`, exponential buckets 1ms-32s.
- `turnstile_siteverify_outcomes.yaml`: counter metric with `outcome`
and `reason` labels for the success-vs-failure breakdown widget.
README updated with the gcloud commands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire up two new tiles to surface the `siteverifyDurationMs` field added
in the previous commit:
- "Turnstile siteverify latency P50 / P95 / P99" — line chart with a
1s threshold marker so a steady regression jumps off the chart.
- "Turnstile siteverify outcomes (by outcome)" — stacked area
breaking down success vs turnstile_failed counts over time.
Both depend on log-based metrics `turnstile_siteverify_duration`
(distribution) and `turnstile_siteverify_outcomes` (counter) that need
to be created once in the GCP Console — README documents the filter,
field, and label extractors so the setup is reproducible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes targeting the visible "..." gap between Pack click and the
first SSE progress event observed after PR #1544 landed:
- Client: add a synthetic `verifying` PackProgressStage so the loading
UI displays "Verifying request..." while the server runs Turnstile
siteverify (typically 100-1000ms before the first 'cache-check' SSE
event arrives). The first onProgress callback from handlePackRequest
overwrites it with the real server-reported stage.
- Server: time the siteverify round-trip in `turnstileMiddleware` and
emit `siteverifyDurationMs` on every outcome (success / network
failure / rejected / action mismatch / hostname mismatch). Success
path adds a structured log with `event: turnstile_siteverify` so
Cloud Monitoring can build a log-based distribution metric for
p50/p95/p99 latency and alert on regressions during Cloudflare
incidents.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
decision(assertion-precision): switch the "no stray leading \`: : \`" guard from \`not.toContain(': : ')\` to an anchored regex \`/^Invalid request: : /\` — both catch the defect today, but the anchored form documents exactly which prefix shape we're guarding against and rules out any legitimate \`: : \` that might appear later in the message
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(drift-guard): claude's follow-up review flagged that the hand-rolled schemaErrorWith fixture can't catch a change in valibot's internal PathItem shape — fixture-based tests would stay green while production fails, so add one real-valibot-issue case
decision(fixture-vs-real): keep the existing fixture-based tests as the bulk of coverage (cheap, focused on classifier logic) and layer a single v.safeParse-driven test on top — the fixtures stay fast, the new test catches shape drift
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
decision(idiomatic-valibot): replace three `v.transform((val) => val.trim())` sites in packRequestSchema with valibot 1.x's first-class `v.trim()` pipe action — purely cosmetic (behavior is identical) but aligns with how the rest of the schema uses dedicated pipe actions (v.minLength, v.maxLength, v.regex) rather than hand-rolled transforms
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(test-ownership): move tests into website/server/tests/ so they collocate with the code under test and stop reaching up through three parents; reviewer follow-up wanted dedicated coverage and the root vs. website/server package boundary makes collocation the right long-term layout
decision(vitest-config): give website/server its own vitest.config.ts + `test` script; root's existing tests/**/*.test.ts include no longer catches server tests since they moved outside that tree, so the two test runs stay independent
decision(tsconfig-test): add tsconfig.test.json extending the build config and lift lint-tsc to `-p tsconfig.test.json` — the build tsconfig's rootDir: "./src" excludes tests/, so a single lint command wouldn't have type-checked them
learned(valibot-instanceof): with tests now resolving valibot from the same website/server/node_modules as validateRequest, the cause-check can go back to `instanceof v.ValiError` — the duck-type workaround was only needed when the root harness and server pulled different valibot copies
constraint(ci-website): added a `test-website-server` job that links the local repomix build the same way lint-website-server does; tests don't actually import repomix today, but colocation means they easily could later and the link step keeps parity
intent(readability): dashboard panels for options usage and cache hit
ratio were rendering with empty legend values (e.g. "compress=",
"removeComments=") making it impossible to distinguish true vs false
series. Every other signal on the dashboard was fine — this was a
subtle API mismatch
decision(labels-plural): GCP Cloud Monitoring widget legend templates
use `${metric.labels.X}` (plural). The dashboard had
`${metric.label.X}` (singular), which silently resolves to the empty
string instead of erroring out. Replaced all 5 occurrences
Applied to the live GCP dashboard via `gcloud monitoring dashboards
update` before committing — legends now render correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(pack-event-test): migrate tests/website/server/packEventSchema.test.ts to the valibot-shaped `{ name, issues: [{ message, path: [{ key }] }] }` fixture so the invalid_format case — which relies on the path fallback — actually exercises the post-migration classifier
decision(path-formatting): replace the hand-rolled `path.map/filter/join` in both packEventSchema.ts and validation.ts with `v.getDotPath(issue)` — the valibot built-in does the same reconstruction and eliminates the two-site drift risk reviewers flagged
fix(validation-error-message): drop the leading `": "` when a valibot issue has no path (top-level checks like MISSING_INPUT / BOTH_PROVIDED), so the rendered AppError message reads cleanly
rejected(cause-walk-depth): coderabbit suggested walking `.cause` recursively up to depth 3 — declined because validateRequest wraps exactly once by contract and no second wrapper exists in the chain; defensive recursion would add surface area for no caller
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(server): complete the project-wide zod → valibot swap started by the CLI config schema in #1489
constraint(pack-event-schema): valibot issue paths are `{ key }` objects, not primitives, so classifyRejectReason's `path === 'format'` fallback had to extract `segment.key` before joining
decision(validation-helper): handle ValiError via `instanceof` rather than the duck-typing used in src/shared/errorHandle.ts — website/server owns its own error chain with no worker-boundary crossings, so the cross-library duck check is unnecessary noise here
learned(server): website/server has no test suite (lint is `tsgo --noEmit` only), so behavior parity was verified manually via a one-off tsx smoke script hitting each reject-reason bucket; MCP tool schemas stay on zod because @modelcontextprotocol/sdk's `AnySchema` is `z3.ZodTypeAny | z4.\$ZodType` with no standard-schema support yet
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #1493 round 5 follow-up (schema-drift tracking) from claude.
intent(eliminate-drift): prior reviews flagged the string-coupling
between packRequestSchema (produces zod messages) and
classifyRejectReason (matches them back to a metric label) as
fragile — a message rewrite in one place would silently land
requests in the 'other' bucket, quietly mislabeling the dashboard.
Extract all 11 messages to a shared MESSAGES module so the
producer, the consumer, and the test's expected values all
reference the same constants
decision(map-over-switch): classifyRejectReason's 11-case switch
becomes a MESSAGE_TO_REASON lookup table. Same runtime behavior,
easier to scan, and the keys are compile-time-typed via the
shared constants (so a typo in either side is a TS error)
decision(preserve-invalid_format-path): 'invalid_format' is keyed by
path (`format`), not by message text. Left as path-match so it
continues to work even if zod's default enum error message changes
improve(test-references-constants): test.each cases now reference
MESSAGES directly. The test continues to catch classifier-logic
drift (wrong label for a known key, missing entry in the lookup)
while schema/classifier drift becomes impossible by construction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #1493 round 3 review from claude.
intent(catch-schema-drift): every previous review round flagged the
fragile string coupling between classifyRejectReason and the zod
messages in packRequestSchema. The earlier response was that adding
tests would require new infrastructure in website/server/ — but
tests/website/cliCommand.test.ts already tests website/ code under
the root vitest harness. Extended the same pattern to cover every
rejection path through the real schema so message edits surface as
CI failures instead of silent 'other'-bucket dashboard drift
decision(table-driven-through-real-schema): the tests invoke
validateRequest(packRequestSchema, input) rather than constructing
synthetic ZodError objects — this catches both classifier drift AND
schema drift in one assertion. 13 tests covering 11 reject labels
+ cause-chain extraction + non-error input
improve(boolean-intent-comment): claude noted that Boolean() collapses
`undefined` (user didn't send field) with `false` (user explicitly
disabled), losing that distinction in the pack_options_usage metric.
This is intentional — the metric answers "what % of packs had
compress enabled" where both "off" and "unspecified" correctly mean
the feature wasn't active. Added a comment so future contributors
don't "fix" it
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #1493 follow-up review from claude.
intent(fix-survivorship-bias): `packOptions` was only logged on success,
but OOM-triggering repos land in pack_error. The whole point of
logging options alongside size metrics is to correlate "which options
were active when things went wrong" — without pack_error coverage
the dashboard answers the question backwards (shows options that
worked, not options that failed)
decision(lift-packoptions): compute `packOptions` once after validation
(same site as `inputType`/`repoHost`) and reference it from both the
success and pack_error logs. No duplication, no survivorship bias.
The `metrics` block (totalTokens/totalFiles) correctly stays on
success only since those values don't exist on failure
improve(remove-dead-branch): after commit 2 the JSON.parse catch
hardcodes `rejectReason: 'invalid_json'` directly, so the
`error.message === 'Invalid JSON in options'` branch in
classifyRejectReason was unreachable. Removed it and added a comment
explaining that pre-zod paths set the label at the call site
improve(errorOptions-type): replaced the hand-rolled
`{ cause?: unknown }` option type on AppError with the built-in
ES2022 `ErrorOptions` — more self-documenting and keeps future
cause-chain additions compatible
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #1493 review comments from gemini-code-assist,
devin-ai-integration, coderabbitai, and claude.
intent(classifier-fix): fix classifyRejectReason returning 'unknown'
for every validation_error — gemini and devin both flagged that
validateRequest wraps ZodError in AppError, which strips the
.issues array the classifier needs. This made the new "Validation
rejections (by reason)" dashboard panel effectively useless, all
rejections collapsing into one 'unknown' bucket
decision(cause-chain): use the native `Error.cause` option
(ES2022) to attach the original ZodError when wrapping in AppError.
Less invasive than adding a custom field; classifyRejectReason now
checks both `.issues` directly on the error AND on `.cause`, so
callers don't need to know which layer wrapped the error
decision(json-catch-preserve): change the JSON.parse bare `catch {}`
to `catch (jsonError)` and pass the original SyntaxError to
logError. Without this the `invalid_json` bucket shows only a
count, leaving an operator no breadcrumbs when it spikes. Also
drops the classifyRejectReason round-trip — rejectReason is
statically known at this call site
rejected(packOptions-snake-case): gemini suggested renaming the
packOptions log fields to snake_case so they literally match the
GCP metric label names. Keeping camelCase in TS code (project
convention) + snake_case in GCP labels (platform convention) is
deliberate — the `EXTRACT(...)` path on the metric bridges them
explicitly. Forcing one to match the other breaks one of the two
ecosystems' idioms
improve(options-panels): coderabbit noted only `compress` had a
dedicated panel even though pack_options_usage exposes 4 labels.
Split into four quarter-width tiles covering compress,
removeComments, outputParsable, and hasIncludePatterns. Also
dropped the "(Tree-sitter)" parenthetical from the compress title
— meaningful to the author, confusing to an on-caller
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(observability): answer three questions that the initial
observability PR couldn't:
(1) what drives OOM risk — size distribution heatmap needs the
data out of pack_completed's `metrics.totalTokens` as a
distribution metric, not just a scalar log field
(2) which differentiating features users actually adopt
(compress / removeComments / outputParsable / custom
include-patterns)
(3) what inputs validation rejects and why — surfaces feature
requests (non-github hosts, oversized files) and abuse patterns
decision(packOptions-in-log): flatten option booleans under a
`packOptions` nested object on pack_completed success logs so a
log-based metric with option labels can extract them without
exploding the existing pack_requests cardinality. Patterns are
logged as `hasIncludePatterns` / `hasIgnorePatterns` booleans only —
raw user input never enters metric labels
decision(rejectReason-enum): classifyRejectReason is a switch on zod
error message strings since the messages are stable (defined in
packRequestSchema.ts in this repo). Non-zod errors (notably the
pre-zod `Invalid JSON in options` path) get an explicit branch so
they don't fall into the generic `unknown` bucket
decision(invalid-json-logging): the JSON-parse catch previously
returned 400 without logging, so `rejectReason=invalid_json` would
have been invisible. Added an explicit logError call on that path
rejected(per-option-metrics): creating separate metrics like
`pack_with_compress` was considered and rejected — 1 metric with
4 boolean labels gives 16 series total (manageable) and lets the
dashboard group by any option without schema sprawl
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(readability): address non-blocking reviewer observations — add
one-line notes where the current behavior looks like an oversight on
first read: (1) why `pack_completed` covers non-completion outcomes
like `rate_limited`, and (2) why `processZipFile` hardcodes
`cached: false` while `remoteRepo` uses the cache
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(cleanliness): eliminate the 3x duplicate getClientInfo call per
request (cloudLogger, rateLimit, packAction each ran ~7 header
lookups). Not a performance issue at current scale, but the
redundancy makes tracing "which field came from which call" harder
decision(lazy-memoization): add the cache inside getClientInfo rather
than a dedicated middleware. cloudflareGuardMiddleware is the first
consumer on /api/* but runs before cloudLogger, so a "populate
clientInfo here" middleware would need to be inserted in front of it.
Lazy memoization keeps existing call sites unchanged and just makes
subsequent calls cheap
decision(stash-type): declare `clientInfo` in hono's ContextVariableMap
in clientInfo.ts itself, next to the type it stashes — colocating the
augmentation with the type keeps future schema changes in one file
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(observability): make pack failure latency visible in the
pack_duration distribution metric so fast errors (permission / invalid
ref) can be distinguished from slow errors (timeouts, OOM-triggered
terminations). Previously only `success` emitted durationMs
decision(startTime-scope): move `startTime` declaration outside the
try block so the catch path can reference it — keeps the single
source of truth for the pack-operation timer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #1483 review comments from coderabbitai, devin-ai-integration,
and claude.
intent(observability): unify the pack_completed log schema across all
emitters so every log-based metric label (`source`, `outcome`,
`limitKind`) actually populates — previously `source` was only emitted
by rateLimit.ts, and the `pack_requests` metric would have had empty
`source` labels on 95%+ of events
decision(schema-location): extract PACK_EVENT + PackOutcome + getRepoHost
to `actions/packEventSchema.ts` (new) and packRequestSchema to
`actions/packRequestSchema.ts` (new). This addresses three reviewer
findings at once: (1) PACK_EVENT duplicated across packAction and
rateLimit, (2) PackOutcome union missing 'rate_limited', (3)
packAction.ts exceeding the 250-line project limit (276 → 204)
decision(buildCfLogField-location): move `buildCfLogField` from
cloudLogger.ts to logger.ts so rateLimit.ts can reuse it — rate-limit
hits are exactly the events where Cloudflare country/ASN matters most
for triage (bots disproportionately hit the limit). Also added early-
return for the all-undefined case per the review suggestion
decision(validation-error-source): move `getClientInfo(c)` above the
try/catch in packAction so the validation_error log can attach
`source` (the other labels — inputType/repoHost/format — genuinely
can't exist before validation, so they're only on success/pack_error)
decision(clientInfo-caveat): add explicit NOTE comment stating cf-ray /
cf-ipcountry / cf-asn / source are spoofable and must never be used
for auth or rate-limiting. Trust is anchored by cloudflareGuard +
CLOUDFLARE_ORIGIN_SECRET, not these headers
rejected(getRepoHost-fallback-rename): keep the fallback default
'github.com' for unparseable URLs. Repomix's own shorthand accepts
'owner/repo' and resolves to github.com — the fallback matches that
behavior. Changing to 'github.com (shorthand)' would create two
semantically-identical buckets in the metric
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(scope-reduction): this repo operates a single GCP project — IaC
for log-based metrics adds maintenance cost (Console drift, stale
YAMLs) without payoff. Metrics are recreated ad-hoc from the Console
when needed
decision(keep-dashboard): the dashboard definition is still worth
committing because it's the user-facing layout, easy to damage by
accidental Console edit, and trivial to restore via
`gcloud monitoring dashboards create --config-from-file=dashboard.json`
decision(readme-slim): strip README to just the restore command —
longer prose belonged to the earlier IaC-lite layout that's now gone
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(observability): make the new pack_completed / source / durationMs
log fields queryable as time series in Cloud Monitoring without
re-running ad-hoc gcloud logging read pipelines during incidents
decision(iac-lite): store metric definitions as standalone YAMLs under
website/server/monitoring/metrics/ + an idempotent bash apply script,
rather than Terraform. The project has no existing IaC for monitoring
and adding a full Terraform setup would dwarf the actual change
decision(labels): constrain pack_requests labels to
outcome/source/input_type/cached/format (max 96 time series). Omitted
repoHost because its cardinality is unbounded — raw value remains in
the log jsonPayload for ad-hoc queries
decision(oom-split): separate oom_terminations and container_killed
counters. They fire on distinct textPayloads from Cloud Run's memory
enforcement paths; keeping them separate makes alert tuning easier
rejected(repoHost-as-label): too many unique hostnames — would create a
time series per repo host and cost scale with traffic diversity
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(observability): make pack-request analytics queryable without
log-regex-spelunking — one event name `pack_completed` + an `outcome`
label covers the full terminal states (success / validation_error /
pack_error / rate_limited), enabling log-based metrics by outcome
without rewriting filters each time
decision(schema): add `event`, `outcome`, `repoHost`, `durationMs`,
`cached` as top-level jsonPayload fields so Cloud Logging log-based
metrics can extract them as labels directly. `durationMs` is numeric
(in addition to the existing human-readable `duration`) so it can be
used as a DISTRIBUTION metric value. `repoHost` captures the pack
target's hostname (or `upload` for ZIP) — useful to spot "all traffic
to one repo" (bot signal) vs organic diversity
decision(cache-reporting): thread a `cached: boolean` flag through
processRemoteRepo/processZipFile by returning `ProcessPackResult =
{ result, cached }` instead of `PackResult` directly. Previously cache
hits were invisible in logs; now we can measure cache hit rate per
outcome/repoHost
decision(rate-limit-logging): emit a `pack_completed` log entry on 429
paths too (both short-term and daily), keyed by `limitKind`. Without
this the rate-limit outcome was invisible in aggregate analytics
rejected(metadata-polluting): attaching `cached` to PackResult.metadata
— that type represents data returned to the client, and a behavioral
flag shouldn't leak into the API response
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(observability): make bot-vs-legit traffic filterable in Cloud Logging
without per-IP whois/ASN lookups — distinguish Cloudflare-proxied requests
from direct-to-origin hits (which are typically bots that found the Cloud
Run URL and spoof the Host header)
decision(log-schema): add top-level `source` ('cloudflare' | 'direct') plus
nested `cf.{ray,country,asn}` so queries like
`jsonPayload.source="direct"` or `jsonPayload.cf.country="SG"` work
without parsing user agents
constraint(cf-asn): `cf-asn` is not a default Cloudflare header — it
requires a Transform Rule injecting `ip.src.asnum`; the field is logged
only when present so nothing breaks before the rule is configured
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Register a gzip 'error' listener so zlib failures don't crash the
Node process via an unhandled EventEmitter error (flagged by
gemini-code-assist and devin-ai-integration).
- Replace the pendingWrites array with a serialized promise chain so
slow-client backpressure bounds memory; compressed chunks are now
awaited one at a time rather than queued unbounded (flagged by
coderabbitai).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>