Repository navigation
feat(flags): shadow-route $feature_flag_called into flag_evaluations #86291
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
trunk-io
merged 6 commits into
master
from
posthog-code/flag-evaluations-shadow-routing
Aug 25, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
a71314c
feat(flags): shadow-route $feature_flag_called into flag_evaluations
haacked 6ee332d
fix(flags): stop oversized flag_evaluations rows from stalling ingestion
haacked e2e04a2
Merge branch 'master' into posthog-code/flag-evaluations-shadow-routing
haacked cb51c55
fix(flags): drop a failed flag_evaluations produce instead of rejecting
haacked 2fba959
Merge branch 'master' into posthog-code/flag-evaluations-shadow-routing
haacked a7fafd6
chore(flags): say what a pending flag_evaluations ack actually blocks
haacked 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
65 changes: 65 additions & 0 deletions
65
nodejs/src/ingestion/common/flag-evaluations/flag-evaluation-row.test.ts
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,65 @@ | ||
| import { DateTime } from 'luxon' | ||
|
|
||
| import { parseJSON } from '~/common/utils/json-parse' | ||
| import { serializeEvent } from '~/ingestion/common/steps/event-processing/emit-event-step' | ||
| import { ISOTimestamp, ProcessedEvent, ProjectId } from '~/types' | ||
|
|
||
| import { mapProcessedEventToFlagEvaluationRow } from './flag-evaluation-row' | ||
|
|
||
| describe('mapProcessedEventToFlagEvaluationRow', () => { | ||
| const processedEvent: ProcessedEvent = { | ||
| uuid: 'event-uuid-1', | ||
| event: '$feature_flag_called', | ||
| properties: { $feature_flag: 'my-flag', $feature_flag_response: true, $group_0: 'org-1' }, | ||
| timestamp: '2024-01-15T10:30:00.123Z' as ISOTimestamp, | ||
| team_id: 42, | ||
| project_id: 7 as ProjectId, | ||
| distinct_id: 'distinct-1', | ||
| elements_chain: '', | ||
| created_at: DateTime.fromISO('2024-01-15T10:31:00.456Z'), | ||
| captured_at: null, | ||
| person_id: 'person-uuid-1', | ||
| person_properties: { email: 'a@example.com' }, | ||
| person_created_at: DateTime.fromISO('2023-01-01T00:00:00.000Z'), | ||
| person_mode: 'full', | ||
| } | ||
|
|
||
| it('narrows the events wire format to the flag_evaluations columns, byte-identical on shared fields', () => { | ||
| const row = mapProcessedEventToFlagEvaluationRow(processedEvent) | ||
| const eventsRow = serializeEvent(processedEvent) | ||
|
|
||
| expect(row.uuid).toBe('event-uuid-1') | ||
| expect(row.event).toBe('$feature_flag_called') | ||
| expect(row.team_id).toBe(42) | ||
| expect(row.distinct_id).toBe('distinct-1') | ||
| expect(row.person_id).toBe('person-uuid-1') | ||
| expect(row.timestamp).toBe('2024-01-15 10:30:00.123') | ||
| expect(row.properties).toBe(eventsRow.properties) | ||
| expect(row.person_properties).toBe(eventsRow.person_properties) | ||
| // Both rows derive created_at from the event, so they agree. Reading the | ||
| // wall clock per serialization instead would stamp two different values. | ||
| expect(row.created_at).toBe(eventsRow.created_at) | ||
| expect(row.created_at).toBe('2024-01-15 10:31:00.456') | ||
| expect(parseJSON(row.properties!).$feature_flag).toBe('my-flag') | ||
| expect(parseJSON(row.person_properties!).email).toBe('a@example.com') | ||
| }) | ||
|
|
||
| it('emits exactly the kafka_flag_evaluations column set', () => { | ||
| // An extra key (elements_chain, person_mode, a materialized column name) | ||
| // is not a column on the Kafka table; keep the wire format in lockstep | ||
| // with posthog/models/flag_evaluations/sql.py. | ||
| const row = mapProcessedEventToFlagEvaluationRow(processedEvent) | ||
|
|
||
| expect(Object.keys(row).sort()).toEqual([ | ||
| 'created_at', | ||
| 'distinct_id', | ||
| 'event', | ||
| 'person_id', | ||
| 'person_properties', | ||
| 'properties', | ||
| 'team_id', | ||
| 'timestamp', | ||
| 'uuid', | ||
| ]) | ||
| }) | ||
| }) |
51 changes: 51 additions & 0 deletions
51
nodejs/src/ingestion/common/flag-evaluations/flag-evaluation-row.ts
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,51 @@ | ||
| import { serializeEvent } from '~/ingestion/common/steps/event-processing/emit-event-step' | ||
| import { ProcessedEvent, RawKafkaEvent } from '~/types' | ||
|
|
||
| /** | ||
| * One row for the kafka_flag_evaluations ClickHouse table (JSONEachRow). | ||
| * Keep in sync with posthog/models/flag_evaluations/sql.py. | ||
| * | ||
| * The table mirrors the events schema, so the row is the events wire format | ||
| * narrowed to exactly the columns the Kafka engine table declares. Going | ||
| * through serializeEvent keeps every shared field byte-identical to what the | ||
| * events table receives for the same event, rather than a second hand-rolled | ||
| * mapping that could drift from it. | ||
| * | ||
| * Deliberately not sent: | ||
| * - `inserted_at`: omitted keys zero-fill in ClickHouse, and the MV treats the | ||
| * epoch sentinel as "absent" and stamps the Kafka message timestamp instead. | ||
| * - `group0..group4_properties`: the events producer does not send them either | ||
| * (they are not in RawKafkaEvent); they zero-fill empty in both tables. | ||
| * - `flag_key`, `response`, `session_id`, `request_id`, `$group_0..4`: the Kafka | ||
| * table does not declare these, and the shard computes them from `properties` | ||
| * as DEFAULT columns. A DEFAULT only fills when the insert omits the column, | ||
| * so naming one here would store the sent value even where it contradicts | ||
| * `properties`. This row type must never grow them. | ||
| */ | ||
| export type FlagEvaluationRow = Pick< | ||
| RawKafkaEvent, | ||
| | 'uuid' | ||
| | 'event' | ||
| | 'properties' | ||
| | 'timestamp' | ||
| | 'team_id' | ||
| | 'distinct_id' | ||
| | 'created_at' | ||
| | 'person_id' | ||
| | 'person_properties' | ||
| > | ||
|
|
||
| export function mapProcessedEventToFlagEvaluationRow(event: ProcessedEvent): FlagEvaluationRow { | ||
| const serialized = serializeEvent(event) | ||
| return { | ||
| uuid: serialized.uuid, | ||
| event: serialized.event, | ||
| properties: serialized.properties, | ||
| timestamp: serialized.timestamp, | ||
| team_id: serialized.team_id, | ||
| distinct_id: serialized.distinct_id, | ||
| created_at: serialized.created_at, | ||
| person_id: serialized.person_id, | ||
| person_properties: serialized.person_properties, | ||
| } | ||
| } | ||
55 changes: 55 additions & 0 deletions
55
nodejs/src/ingestion/common/flag-evaluations/flag-evaluations-service.test.ts
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,55 @@ | ||
| import { IngestionLane } from '~/ingestion/config' | ||
|
|
||
| import { FlagEvaluationsEnvConfig, createFlagEvaluationsService } from './flag-evaluations-service' | ||
|
|
||
| describe('FlagEvaluationsService', () => { | ||
| const envConfig = ( | ||
| mode: string, | ||
| teams = '*', | ||
| excludedTeams = '', | ||
| topic = 'clickhouse_flag_evaluations', | ||
| lane: IngestionLane | null = 'main' | ||
| ): FlagEvaluationsEnvConfig => ({ | ||
| INGESTION_LANE: lane, | ||
| INGESTION_FLAG_EVALUATIONS_MODE: mode, | ||
| INGESTION_FLAG_EVALUATIONS_TEAMS: teams, | ||
| INGESTION_FLAG_EVALUATIONS_EXCLUDED_TEAMS: excludedTeams, | ||
| INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC: topic, | ||
| }) | ||
|
|
||
| describe('createFlagEvaluationsService', () => { | ||
| // The wildcard-exclusion case is the escape hatch: it must fail toward the | ||
| // events table, not toward excluding everyone from being excluded. The | ||
| // empty-topic case guards the fork from producing into a nameless topic. | ||
| it.each([ | ||
| ['mode is disabled', envConfig('disabled'), false], | ||
| ['mode is invalid', envConfig('garbage'), false], | ||
| ['the output topic is empty', envConfig('dual_write', '*', '', ''), false], | ||
| ['excluded teams is the wildcard', envConfig('dual_write', '*', '*'), false], | ||
| ['mode is dual_write', envConfig('dual_write'), true], | ||
| // The backfill owns history, so a delayed lane must never fork even | ||
| // when the env vars say dual_write. | ||
| ['the lane is historical', envConfig('dual_write', '*', '', 'topic', 'historical'), false], | ||
| ['the lane is async', envConfig('dual_write', '*', '', 'topic', 'async'), false], | ||
| ['the lane is overflow', envConfig('dual_write', '*', '', 'topic', 'overflow'), true], | ||
| ['no lane is set (local dev)', envConfig('dual_write', '*', '', 'topic', null), true], | ||
| ['the teams allowlist is empty', envConfig('dual_write', ''), false], | ||
| ])('builds a service when %s -> %s', (_name, config, expected) => { | ||
| expect(createFlagEvaluationsService(config) !== undefined).toBe(expected) | ||
| }) | ||
| }) | ||
|
|
||
| describe('isEnabledForTeam', () => { | ||
| it.each([ | ||
| ['*', '', 5, true], | ||
| ['*', '5', 5, false], | ||
| ['1,2', '', 2, true], | ||
| ['1,2', '', 3, false], | ||
| ['1,2', '2', 2, false], | ||
| ])('teams=%s excluded=%s team=%i -> %s', (teams, excluded, teamId, expected) => { | ||
| const service = createFlagEvaluationsService(envConfig('dual_write', teams, excluded)) | ||
|
|
||
| expect(service?.isEnabledForTeam(teamId)).toBe(expected) | ||
| }) | ||
| }) | ||
| }) |
108 changes: 108 additions & 0 deletions
108
nodejs/src/ingestion/common/flag-evaluations/flag-evaluations-service.ts
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,108 @@ | ||
| import { parseTeamsList } from '~/common/utils/env-utils' | ||
| import { logger } from '~/common/utils/logger' | ||
| import { buildTeamGate } from '~/ingestion/common/team-gate' | ||
| import { | ||
| IngestionConsumerConfig, | ||
| IngestionLane, | ||
| IngestionOutputsConfig, | ||
| REALTIME_INGESTION_LANES, | ||
| } from '~/ingestion/config' | ||
| import { ValueMatcher } from '~/types' | ||
|
|
||
| export interface FlagEvaluationsConfig { | ||
| /** '*' for all teams, or an explicit allowlist of team IDs. */ | ||
| teams: number[] | '*' | ||
| /** Escape hatch: teams never forked, even when `teams` is '*'. */ | ||
| excludedTeams: number[] | ||
| } | ||
|
|
||
| /** | ||
| * Gate for the $feature_flag_called fork that shadow-writes flag evaluations to | ||
| * the ClickHouse flag_evaluations table (via the clickhouse_flag_evaluations | ||
| * topic) while the event continues to the events table unchanged. | ||
| * | ||
| * The shadow write is never load-bearing for an individual event, but the batch | ||
| * does not commit its offsets until the broker answers. See | ||
| * createForkFlagEvaluationsStep for the ack contract and | ||
| * flagEvaluationsPendingAcks for the stall it can cause. | ||
| * | ||
| * Shedding that dependency during an incident takes BOTH env vars, not just the | ||
| * mode: the mode stops a running consumer from forking, but startup topic | ||
| * verification is driven by the output registration, which the mode does not | ||
| * touch. A pod that restarts with INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC still | ||
| * set fails to start at all while the topic is unreachable. Clear the topic too. | ||
| */ | ||
| export class FlagEvaluationsService { | ||
| private isEnabled: ValueMatcher<number> | ||
|
|
||
| constructor(config: FlagEvaluationsConfig) { | ||
| this.isEnabled = buildTeamGate(config.teams, config.excludedTeams) | ||
| } | ||
|
|
||
| isEnabledForTeam(teamId: number): boolean { | ||
| return this.isEnabled(teamId) | ||
| } | ||
| } | ||
|
|
||
| export type FlagEvaluationsEnvConfig = Pick< | ||
| IngestionConsumerConfig, | ||
| | 'INGESTION_LANE' | ||
| | 'INGESTION_FLAG_EVALUATIONS_MODE' | ||
| | 'INGESTION_FLAG_EVALUATIONS_TEAMS' | ||
| | 'INGESTION_FLAG_EVALUATIONS_EXCLUDED_TEAMS' | ||
| > & | ||
| Pick<IngestionOutputsConfig, 'INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC'> | ||
|
|
||
| /** | ||
| * Lanes that may fork: the real-time lanes plus `null` for local dev, where no | ||
| * lane is set. Derived from REALTIME_INGESTION_LANES so a new real-time lane is | ||
| * covered automatically; delayed lanes are excluded by construction. | ||
| * | ||
| * 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 holds even if the env vars leak | ||
| * into a config shared across lanes. | ||
| */ | ||
| const FLAG_EVALUATIONS_ALLOWED_LANES: readonly (IngestionLane | null)[] = [...REALTIME_INGESTION_LANES, null] | ||
|
|
||
| /** | ||
| * Builds the flag evaluations service, or undefined when the fork is off. The | ||
| * pipeline composes the fork step out entirely when this returns undefined. | ||
| */ | ||
| export function createFlagEvaluationsService(envConfig: FlagEvaluationsEnvConfig): FlagEvaluationsService | undefined { | ||
| const mode = envConfig.INGESTION_FLAG_EVALUATIONS_MODE | ||
| if (mode !== 'dual_write') { | ||
| if (mode !== 'disabled') { | ||
| logger.warn('Invalid INGESTION_FLAG_EVALUATIONS_MODE, falling back to disabled', { mode }) | ||
| } | ||
| return undefined | ||
| } | ||
| if (!envConfig.INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC) { | ||
| // An empty topic also skips the startup topic-existence check, so producing | ||
| // here would fail at runtime instead. See the enable ordering in config.ts. | ||
| logger.warn( | ||
| 'INGESTION_FLAG_EVALUATIONS_MODE is set but INGESTION_OUTPUT_FLAG_EVALUATIONS_TOPIC is empty, not forking' | ||
| ) | ||
| return undefined | ||
| } | ||
| if (!FLAG_EVALUATIONS_ALLOWED_LANES.includes(envConfig.INGESTION_LANE)) { | ||
| logger.warn('Flag evaluations fork is not supported on this ingestion lane, disabling', { | ||
| lane: envConfig.INGESTION_LANE, | ||
| }) | ||
| return undefined | ||
| } | ||
| const excludedTeams = parseTeamsList(envConfig.INGESTION_FLAG_EVALUATIONS_EXCLUDED_TEAMS) | ||
| if (excludedTeams === '*') { | ||
| // An operator's '*' exclusion means "off": the escape hatch fails toward not forking. | ||
| logger.warn('INGESTION_FLAG_EVALUATIONS_EXCLUDED_TEAMS is "*", disabling the flag evaluations fork') | ||
| return undefined | ||
| } | ||
| const teams = parseTeamsList(envConfig.INGESTION_FLAG_EVALUATIONS_TEAMS) | ||
| if (teams !== '*' && teams.length === 0) { | ||
| // Same shape as the empty-topic case: an allowlist naming nobody is off, so | ||
| // compose the step out rather than paying it per event to gate every team away. | ||
| logger.warn('INGESTION_FLAG_EVALUATIONS_MODE is set but INGESTION_FLAG_EVALUATIONS_TEAMS is empty, not forking') | ||
| return undefined | ||
| } | ||
| return new FlagEvaluationsService({ teams, excludedTeams }) | ||
| } |
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
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.