Repository navigation
feat(flags): shadow-route $feature_flag_called into flag_evaluations - #86291
Conversation
|
😎 Merged successfully - details. |
🤖 CI report✅ Trunk lane — non-backend laneThis PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes. |
b86c079 to
59c0779
Compare
🦔 ReviewHog reviewed this pull requestFound 1 must fix, 0 should fix, 1 consider. Published 2 findings (view the review). Resolved comments: 1 fixed, 1 declined |
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
6704401 to
292e654
Compare
Adds an env-gated fork to the analytics ingestion pipeline that narrows $feature_flag_called events to flag_evaluations rows and produces them to the clickhouse_flag_evaluations topic, while the event continues to the events table unchanged. flag_evaluations mirrors the events schema with the full properties JSON as source of truth, so the row is the events wire format (via serializeEvent) narrowed to the Kafka table's columns. Shared fields are byte-identical to what the events table receives for the same event. The shard materializes flag_key, response, session_id, request_id, and $group_0..4 out of properties; the producer never sends them. The fork sits between create-event and emit-event, where the fully assembled row exists, and matches on the event name so the $experiment_exposure duplicate that create-event appends for allowlisted teams stays out of the shadow table. Requiring the service in createForkFlagEvaluationsStep keeps composition as the single off-switch. A lane gate restricts the fork to the real-time lanes. The backfill owns history for this table, so a delayed lane that also forked would write rows the backfill already covers, double-counting the per-flag usage the table exists to answer. The gate mirrors the dedup service's and holds even if the env vars leak into a config shared across lanes. The produce ack rides in the batch's side effects, so an unwritable topic leaves it pending and the consumer stops committing offsets. Neither ack arm runs while it is merely pending, so a pending-acks gauge carries that signal, and the rejection arm counts a produce_failed outcome rather than discarding it. Ships dark. The mode defaults to disabled, the output topic defaults empty so startup topic verification cannot fail a fleet whose broker lacks clickhouse_flag_evaluations, and the teams allowlist defaults empty so flipping the mode alone forks nobody. Enabling is: create the topic, set the topic env, set the mode, name teams. Mode without topic or teams is a logged no-op. Claude-Session: https://claude.ai/code/session_01MM3mC1me5VN3gwnvKrDawC
292e654 to
a71314c
Compare
Prompt To Fix All With AI### Issue 1
nodejs/src/ingestion/common/steps/event-processing/fork-flag-evaluations-step.ts:102-111
**Promise-chain acknowledgement handling**
The new Kafka acknowledgement lifecycle uses `ack.then(...)` instead of the repository's preferred async/await style, making its pending, success, and failure behavior harder to follow and maintain consistently.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "feat(flags): shadow-route $feature_flag_..." | Re-trigger Greptile |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
There was a problem hiding this comment.
Pull request overview
Adds a dark-by-default ingestion fork that writes eligible $feature_flag_called events to flag_evaluations while preserving the existing events path.
Changes:
- Adds topic, output, rollout, team, and lane configuration.
- Maps events to flag-evaluation rows and reuses
created_at. - Adds unit and end-to-end coverage.
Review findings:
- Critical (2 votes): Partial queue failures can replay rows and double-count usage without idempotent deduplication.
- Critical (1 vote):
MessageSizeTooLargecan repeatedly halt the consumer instead of being treated as a continued fork failure. - Critical (1 vote): Already-rejected acknowledgements may be cleaned up before batch draining, allowing offsets to commit.
- Moderate (2 votes): Empty team configuration should disable the service as a logged no-op.
- Nit (2 votes): Default-mode E2E coverage should send an event and verify both destinations.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Summary |
|---|---|
nodejs/tests/ingestion/flag-evaluations-e2e.test.ts |
Covers end-to-end flag-evaluation ingestion. |
nodejs/tests/helpers/kafka.ts |
Supports test Kafka topic setup. |
nodejs/tests/helpers/ingestion-outputs.ts |
Adds test output wiring. |
nodejs/tests/helpers/ingestion-e2e.ts |
Provides ingestion query and polling helpers. |
nodejs/src/types.ts |
Updates the processed-event timestamp contract. |
nodejs/src/servers/ingestion-api-server.ts |
Wires the service into API ingestion. |
nodejs/src/ingestion/pipelines/analytics/outputs/registry.ts |
Registers the new output. |
nodejs/src/ingestion/pipelines/analytics/joined-ingestion-pipeline.ts |
Propagates the service through the pipeline. |
nodejs/src/ingestion/pipelines/analytics/event-subpipeline.ts |
Inserts the fork before normal emission. |
nodejs/src/ingestion/pipelines/ai/steps/split-ai-events-step.test.ts |
Covers AI event-splitting compatibility. |
nodejs/src/ingestion/ingestion-consumer.ts |
Initializes the service for Kafka ingestion. |
nodejs/src/ingestion/config.ts |
Defines rollout and output configuration. |
nodejs/src/ingestion/common/team-gate.ts |
Provides shared team gating. |
nodejs/src/ingestion/common/steps/event-processing/fork-flag-evaluations-step.ts |
Queues shadow writes and tracks acknowledgements. |
nodejs/src/ingestion/common/steps/event-processing/fork-flag-evaluations-step.test.ts |
Tests fork routing, outcomes, and failures. |
nodejs/src/ingestion/common/steps/event-processing/emit-event-step.ts |
Reuses the assembled event timestamp. |
nodejs/src/ingestion/common/steps/event-processing/emit-event-step.test.ts |
Tests event emission behavior. |
nodejs/src/ingestion/common/steps/event-processing/create-event.ts |
Assigns created_at during event assembly. |
nodejs/src/ingestion/common/steps/event-processing/create-event-step.test.ts |
Tests event creation and timestamp handling. |
nodejs/src/ingestion/common/flag-evaluations/flag-evaluations-service.ts |
Implements rollout and lane gating. |
nodejs/src/ingestion/common/flag-evaluations/flag-evaluations-service.test.ts |
Tests configuration and gating behavior. |
nodejs/src/ingestion/common/flag-evaluations/flag-evaluation-row.ts |
Maps events to the destination row shape. |
nodejs/src/ingestion/common/flag-evaluations/flag-evaluation-row.test.ts |
Tests row mapping and columns. |
nodejs/src/ingestion/common/feature-flag-called-dedup/feature-flag-called-dedup-service.ts |
Aligns deduplication with realtime lanes. |
nodejs/src/common/outputs/index.ts |
Defines the new output identifier. |
nodejs/src/common/config/kafka-topics.ts |
Defines the flag-evaluations topic. |
Suppressed comments (3)
nodejs/src/ingestion/common/flag-evaluations/flag-evaluation-row.ts:39
- This mapping calls
serializeEvent, whileemit-event-stepimmediately serializes the sameProcessedEventagain. Every enabled flag event therefore JSON-stringifies both properties blobs and repeats the sanitization/allocation work, which can materially raise CPU and GC cost for a high-volume or wildcard rollout. Reuse one serializedRawKafkaEventfor both outputs instead of serializing the event twice.
const serialized = serializeEvent(event)
nodejs/src/ingestion/common/steps/event-processing/create-event.ts:102
- The existing create-event test only checks that
created_atis populated; no automated path asserts that the stamp generated here is exactly the one emitted to both outputs. Since this change affects every event and claims timestamp parity, add an assertion in the dual-write E2E thatflagEvaluations[0].created_atequalsevents[0].created_at.
created_at: DateTime.utc(),
nodejs/tests/ingestion/flag-evaluations-e2e.test.ts:107
- The key cross-output invariant for the new
created_atstamp is not asserted here: the test comparesperson_idbut never compares the two timestamps. A regression that restores per-serialization timestamping would pass the current unit tests and this E2E. Assert thatflagEvaluations[0].created_atequalsevents[0].created_at.
// person_id must match what the events table stamped for the same
// event — the parity per-flag uniques depend on.
expect(flagEvaluations[0].person_id).not.toBe('00000000-0000-0000-0000-000000000000')
expect(flagEvaluations[0].person_id).toBe(events[0].person_id)
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
An oversized shadow row is the same size on redelivery, so gating the batch's offset commit on it replays the partition forever. Skip it the way emit-event skips an oversized event, and keep transient broker failures blocking. Also disable the fork when the teams allowlist is empty, instead of paying the per-event filter to gate every team away. Claude-Session: https://claude.ai/code/session_012oNtypm7hAwx4kFwKZMnag
|
Reviews (2): Last reviewed commit: "fix(flags): stop oversized flag_evaluati..." | Re-trigger Greptile |
|
Reviews (3): Last reviewed commit: "fix(flags): stop oversized flag_evaluati..." | Re-trigger Greptile |
The fork's rejected ack only holds the offset commit on consumer v2. On consumer v1 the cleanup callback stores the offset from a `.finally` regardless, then the unhandled rejection stops the process. ingestion-api-server stops the pod for every team it serves, and the gRPC driver fails every open stream. The step cannot know which host runs it, so a terminal produce failure now counts produce_failed and drops the shadow row. A produce still in flight keeps holding the batch. The side-effect contract that makes this necessary now sits on PipelineContext.sideEffects, where the next step author will find it. Also count one outcome per event in the catch path, and record that the fork's env vars belong on a split lane's base values.yaml rather than the Rust consumer release. Claude-Session: https://claude.ai/code/session_012oNtypm7hAwx4kFwKZMnag
"Holds the batch" did not say which mechanism stalls. Name it: the batch does not commit its offsets until the broker answers. Claude-Session: https://claude.ai/code/session_012oNtypm7hAwx4kFwKZMnag
eli-r-ph
left a comment
There was a problem hiding this comment.
thanks for the updates! 🚀
Problem
Anyone measuring per-flag usage still has to scan the events table, because the table built to serve those queries has never had a producer.
flag_evaluations, its schema mirror, its deletion sweep, and its column kinds all landed first (#72990, #80994, #82056, #83382). This PR adds the missing producer.#83382 was the last blocker. It recreated the table to make the typed columns assignable, which was only free while the table had no producer.
Changes
$feature_flag_calledevents intoflag_evaluations.events.created_at, which HogQL exposes, now records event-assembly time instead of serialize time. The gap is milliseconds, and both tables now agree on the value for the same event.INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC, setINGESTION_FLAG_EVALUATIONS_MODEtodual_write, and name teams inINGESTION_FLAG_EVALUATIONS_TEAMS. Mode without topic or teams is a logged no-op.INGESTION_FLAG_EVALUATIONS_EXCLUDED_TEAMSholds back named teams. Setting it to*disables the fork.flag_evaluationsand still reaches the events table unchanged.The decisions below carry the correctness of this PR. The rest of the diff is mechanical wiring of config, outputs, and topics.
$experiment_exposureduplicate that create-event appends for allowlisted teams stays out of the shadow table.serializeEvent, so shared fields are byte-identical to what the events table receives for the same event.created_atis now set once at create-event and reused, where each serialization previously stamped it fresh.flag_key,response,session_id,request_id, and$group_0..4, which the shard computes fromproperties.DEFAULT, which only fills when the insert omits it, so a producer that named one would store a value contradictingproperties.ingestion_flag_evaluations_pending_ackscarries that signal.produce_failedand drops the shadow row..finallyregardless, then stops the process on the unhandled rejection.ingestion-api-serverturns one rejection into a pod shutdown for every team it serves, and the gRPC driver fails every open stream.PipelineContext.sideEffectsnow documents that contract at the declaration site.Note
Shedding the topic dependency during an incident takes both env vars, not just the mode. The mode stops a running consumer from forking, but startup topic verification follows the output registration, which the mode does not touch. A pod that restarts with
INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPICstill set will not start while the topic is unreachable.Before
flowchart LR CE[create-event] --> EE[emit-event] EE --> EV[(events)] classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000; class CE,EE phBlue; class EV phGray;After
flowchart LR CE[create-event] --> FK[fork-flag-evaluations] FK --> EE[emit-event] EE --> EV[(events)] FK --> TP[clickhouse_flag_evaluations] TP --> KT[kafka_flag_evaluations] KT --> MV[flag_evaluations_mv] MV --> FE[(flag_evaluations)] classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000; classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000; class CE,EE,MV phBlue; class FK,TP,KT phYellow; class EV,FE phGray;How did you test this code?
An end-to-end test covers the whole path, which no unit test can. A topic-key typo, a row column ClickHouse cannot parse, or a broken projection all produce "row queued but never lands".
dual_writemode:flag_keyandresponseread back computed fromproperties, though the producer sends neither.dual_writemode:person_idmatches what the events table stamped for the same event, which per-flag uniques depend on.flag_evaluationsstays empty.Unit tests cover the row mapping, the service, and the fork step. New groups catch three regressions no existing test did: a delayed lane forking when the env vars say
dual_write, aproduce_failedoutcome going uncounted, and a pending ack moving no counter at all, which is the stall.The outage behavior was measured against a second broker carrying only
flag_evaluations, so that output's cluster could be stopped while the events output kept running. With it stopped, the event still reached the events table and the batch's side-effect drain stayed pending past 60 seconds with no error raised. It stalls rather than dropping.The
created_atchange touches every event, so its consumers were surveyed rather than reasoned about. It sits in noORDER BY,PARTITION BY,TTL, or version-column position; the events ReplacingMergeTree version is_timestamp. No query computes lag from it. The one behavioral consumer isdistinct_id_usage_mv, which buckets by minute.Not checked: any behavior on a real multi-shard cluster, any load or throughput characteristic of the fork, and customer-authored HogQL that filters on
events.created_at, which is stored per team rather than in this repo.Manual test plan
Unit tests already cover the row mapping and column set, the service's config parsing (invalid mode, empty topic, wildcard exclusion), the team gate, and the fork step's outcomes and failure isolation; one e2e covers
dual_writeand default mode through the ingestion consumer. This manual plan targets what none of them reach: the startup/deploy safety claim, the four-step enable sequence and its partial states, theingestion-api-serverentrypoint, lane and dedup interactions, thecreated_atchange's blast radius beyond flag events, and rollback under a topic outage.Deploy safety and the enable sequence
clickhouse_flag_evaluationson the broker. LeaveINGESTION_OUTPUT_FLAG_EVALUATIONS_TOPICempty. Start the ingestion consumer and send a$pageview. Confirm startup reaches ready with noOutput topic verification failederror and the event lands inevents.IngestionOutputsComponent.start()resolves with an empty topic (checkTopicExists returns early), and events still reachevents.INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC=clickhouse_flag_evaluationswith the topic absent from the broker. Start the consumer. Confirm it fails fast withOutput topic verification failed for: flag_evaluationsrather than starting and failing per event.Output topic verification failed for: flag_evaluations. Caveat: the local Redpanda hasauto_create_topics_enabled=trueand the metadata probe created the topic as a side effect, but the check failed first.INGESTION_FLAG_EVALUATIONS_MODE=dual_writewithINGESTION_OUTPUT_FLAG_EVALUATIONS_TOPICempty. Start the consumer and send a$feature_flag_called. Confirm the warningINGESTION_FLAG_EVALUATIONS_MODE is set but INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC is empty, not forkingand that the event still reachesevents.flag_evaluations, event present inevents.dual_writeand a real topic, leavingINGESTION_FLAG_EVALUATIONS_TEAMSempty. Send a$feature_flag_called. Confirm zero messages onclickhouse_flag_evaluationsand the event present inevents.events.INGESTION_FLAG_EVALUATIONS_MODE=garbage. Start the consumer and send a$feature_flag_called. Confirm the warningInvalid INGESTION_FLAG_EVALUATIONS_MODE, falling back to disabledand no row inflag_evaluations.Invalid INGESTION_FLAG_EVALUATIONS_MODEwarning logged, zero rows.Rollout gating on a live stack
INGESTION_FLAG_EVALUATIONS_TEAMSto one team ID. Send$feature_flag_calledfrom that team and from a second team. Confirm only the named team's row appears inflag_evaluationsand both events appear inevents.TEAMS=*forks andTEAMS=<other id>does not. Two teams were not run through one consumer simultaneously.INGESTION_FLAG_EVALUATIONS_TEAMS=*andINGESTION_FLAG_EVALUATIONS_EXCLUDED_TEAMS=<team_id>. Send a$feature_flag_calledfrom that team. Confirm no row inflag_evaluations,ingestion_flag_evaluations_events_total{outcome="continued_team_not_enabled"}increments, and the event reachesevents.TEAMS=*plus the team excluded;continued_team_not_enabledincrements (see metrics item).dual_writeand teams*, setINGESTION_FLAG_EVALUATIONS_EXCLUDED_TEAMS=*and restart. Confirm the warningINGESTION_FLAG_EVALUATIONS_EXCLUDED_TEAMS is "*", disabling the flag evaluations forkand that no fork occurs for any team.disabling the flag evaluations forkwarning logged, no fork.ingestion-api-serverentrypoint: Apply the samedual_writeconfig toingestion-api-serverrather than the ingestion consumer. POST a batch containing a$feature_flag_called. Confirm the row lands inflag_evaluations, since the e2e only exercisesIngestionConsumer.createFlagEvaluationsService(this.config)and passes it into the samecreateJoinedIngestionPipeline. No HTTP round trip was run, because booting the server stands up its own Postgres, Redis, Kafka, and HTTP stack.Lane and cross-feature interactions
dual_writeon the overflow lane and force a$feature_flag_calledto overflow. Confirm the row still lands inflag_evaluationswith the correctteam_id.INGESTION_LANE=overflowand dual_write, the row landed inflag_evaluationswith the rightteam_id.disabledwhile realtime runsdual_write. Replay a historical$feature_flag_called. Confirm no message onclickhouse_flag_evaluationsfrom that lane and the event still lands inevents.INGESTION_LANE=historicalwithdual_writeforked. A lane gate now restricts the fork to the real-time lanes, matching the dedup service, with unit cases covering historical, async, overflow, and the unset lane.INGESTION_FEATURE_FLAG_CALLED_DEDUP_MODE=dropalongsidedual_write. Send two identical$feature_flag_calledevents inside the dedup TTL. Confirm exactly one row ineventsand exactly one inflag_evaluations, not two in either.dropproduced exactly one row ineventsand one inflag_evaluations.$feature_flag_calledcarrying$groups. Queryflag_evaluations. Confirm$group_0reads back populated even though the producer never sends it.$group_0read back asorg-42fromflag_evaluationsthough the producer never sends it.$feature_flag_calledwithprocess_person_profile=false. Confirmflag_evaluations.person_idmatchesevents.person_idfor that UUID, whatever value person processing assigned.flag_evaluations.person_id==events.person_idwith$process_person_profile=false(a real UUID, not the zero sentinel).The
created_atchange (affects all events, not just flag calls)created_at: With the fork disabled, send a$pageview. RunSELECT created_at FROM eventsfor that UUID. Confirm it is populated, within seconds of now, and not a1970epoch value.$pageviewcreated_at was a current timestamp, not 1970.EXPERIMENT_EXPOSURE_DUPLICATION_TEAMS, send a$feature_flag_called. Confirm the$feature_flag_calledand$experiment_exposurerows ineventscarry identicalcreated_at, and only the former appears inflag_evaluations.eventsrows carried an identicalcreated_at; only$feature_flag_calledreachedflag_evaluations.created_at: Querycreated_atvia HogQL for a recent event. Confirm it returns event-assembly time and no query error.created_atto the ClickHouse column (posthog/hogql/database/schema/events.py:116), and the column value was confirmed current. No HogQL query was executed, because that needs the Django app running.Observability and rollback
$feature_flag, and one carrying$set). Scrape/_metrics. Confirmingestion_flag_evaluations_events_totalwith labelsdual_written,continued_team_not_enabled, andcontinued_invalid_flag_key, plusingestion_flag_evaluations_set_props_total.dual_written,continued_team_not_enabled,continued_invalid_flag_key, andingestion_flag_evaluations_set_props_totalall present. The fork also exportsingestion_flag_evaluations_pending_acksand aproduce_failedoutcome, added after the outage run showed a stall moved no counter. Registry contents, not an HTTP GET.clickhouse_flag_evaluationsunwritable and send events (requires stopping the broker or toxiproxy). Confirm the consumer stalls without committing offsets, then restore the topic and confirm every event still reacheseventswith no loss.events, and the batch's side-effect drain stayed pending past 60 seconds with no error raised. It stalls rather than dropping. No log line named the fork, soingestion_flag_evaluations_pending_acksis what attributes a stall to it. Both consumers do surface the wait itself atCONSUMER_BACKGROUND_TASK_TIMEOUT_MS.INGESTION_FLAG_EVALUATIONS_MODE=disabledand restart. Confirm the consumer resumes, events flow toevents, and no new rows appear inflag_evaluations.Output topic verification failed for: flag_evaluations); clearingINGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIClets it start. During an incident, where pods restart, the mode alone is not enough.events.created_atconsumer surveyThe one change in this branch that touches every event, not just flag calls, so its blast radius was surveyed directly rather than reasoned about.
No structural role.
created_atappears in no ORDER BY, PRIMARY KEY, PARTITION BY, SAMPLE BY, TTL, or version-column position on any events table. The ReplacingMergeTree version column is_timestamp(Kafka message time), notcreated_at(posthog/models/event/sql.py:209,213,680). Dedup and sort semantics are unchanged.No lag or latency queries. Sweeps for
dateDiff(... created_at ...),created_at - timestamp, andmin/max/argMin(created_at)returned zero events-table hits.One behavioral consumer, negligible.
distinct_id_usage_mvbuckets events into minutes bycreated_at(posthog/models/distinct_id_usage/sql.py:98-105). A millisecond shift can move a single event across a minute boundary in a usage counter with a 7-day TTL.Everything else is value passthrough: batch exports (whose interval bounds use
inserted_atortimestamp, nevercreated_at), the events MVs, HogQL exposure, and frontend display. The value moves earlier by single-digit milliseconds, and nothing branches on it.Not enumerable from the repo: customer-authored HogQL and saved insights are stored per-team in Postgres. The column's documented meaning ("server-side ingestion time") is preserved and the value moves earlier, so boundary predicates are unaffected in any practical sense.
Automatic notifications
Docs update
None. No public docs describe this table yet.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Written in Claude Code. Skills invoked:
/writing-code-comments,/writing-tests,/writing-pr-descriptions,/test-plan,/squash,/create-pr.The branch was written on 2026-08-11 and sat unopened while #83382 landed. A later session rebased it and re-verified it against the recreated table, which produced one correctness fix: the row type documented the typed columns as
MATERIALIZED, which #83382 made false. UnderDEFAULTthe distinction matters, because a sent value would win over the computed one.A
/review-code --fixpass found the blocking issue that the output was registered unconditionally with a real topic default, so startup topic verification failed any broker lacking the topic. The fix added the empty-topic default and the service's topic guard.This session ran the manual test plan above against a local stack. Three findings came out of it and are in the diff. The e2e polled only the
flag_evaluationsread before asserting onevents, which passed one run in four; it now polls both.createFlagEvaluationsService(this.config)never typechecked, because the consumer's config type did not surfaceINGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC. The fork had no lane gate while the dedup service beside it did, and the user chose to add one rather than document the difference.A later session addressed review comments from @eli-r-ph and Graphite. That pass changed the rejection contract: the ack no longer rejects, because only consumer v2 honors a rejected side effect, while v1 stops the process and
ingestion-api-serverstops the pod. It also corrected the "silent stall" claim here and in the docstrings, fixed a double count in the fork-error outcome, and recorded which Helm release carries the env vars. Skills invoked in that pass:/address-pr-reviews,/writing-code-comments,/writing-tests,/writing-pr-descriptions.Nothing in this PR derives from session-private material. The measurements come from the local dev stack.