feat(flags): server-side feature flags + upstream kill switches + live backfill config

Add server-side PostHog feature-flag evaluation to the API and wire three
high-leverage uses. The flags live in PostHog (project 127747), dormant and
fail-open, so this commit is a no-op until POSTHOG_PERSONAL_API_KEY is set and
a switch is flipped.

Phase 1 - upstream kill switches:
  PostHogService gains local flag evaluation (onlyEvaluateLocally +
  sendFeatureFlagEvents:false -> zero decode latency, no VIN leak) and
  isSourceLive(). Guards at each source's decode entry (parts-catalogs, emex,
  pl24 whole-source + per-brand via LEGACY_ARCH_SOURCE_TAG) let a flailing
  upstream be disabled from the PostHog UI in ~5s instead of a
  code-fix -> dev -> prod redeploy. Fail-open: any unresolved flag keeps the
  source live, so a PostHog outage can never black out decoding.

Phase 2 - guarded rollout primitive:
  isEnabled()/variant() with VIN bucketing, ready to ramp a new decode/parser
  path 0->100% behind a decode-*-v2 flag (recipe in feature-flags-strategy.md).

Phase 4 - remote-config ops tuning:
  prefetch-worker reads cfg-backfill-tuning to retune backfill
  batchSize/maxBacklog/businessHoursOnly live; malformed/missing -> the
  compiled-in constants.

POSTHOG_PERSONAL_API_KEY wired into the api + worker compose blocks (empty ->
flags inert, no added latency). Tests updated for the new constructor params.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 00:21:52 +03:00
parent 6daf59fe8a
commit ed45021d71
9 changed files with 187 additions and 12 deletions

View File

@@ -17,6 +17,7 @@ function makeConfig(overrides: Record<string, string>): ConfigService {
const browser = {} as never;
const redis = { set: vi.fn() } as never;
const posthog = {} as never;
describe("EmexService proxy-port coercion (regression)", () => {
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
@@ -27,7 +28,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_END: "10099",
});
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
expect(() => new EmexService(config, browser, redis)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
});
it("constructs with a single-port range (string env)", () => {
@@ -36,7 +37,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "823",
EMEX_PROXY_PORT_END: "823",
});
expect(() => new EmexService(config, browser, redis)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
});
it("falls back to a valid default when the port env is garbage", () => {
@@ -45,11 +46,11 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "not-a-number",
EMEX_PROXY_PORT_END: "999999",
});
expect(() => new EmexService(config, browser, redis)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
});
it("constructs cleanly with the proxy disabled", () => {
const config = makeConfig({ EMEX_USE_PROXY: "false" });
expect(() => new EmexService(config, browser, redis)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
});
});

View File

@@ -21,6 +21,7 @@ import { ConfigService } from "@nestjs/config";
import { ProxyAgent } from "undici";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
import { EmexBrowserService } from "./emex.browser";
@@ -136,6 +137,7 @@ export class EmexService {
private configService: ConfigService,
private browserService: EmexBrowserService,
private redis: RedisService,
private posthog: PostHogService,
) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
@@ -618,6 +620,10 @@ export class EmexService {
| { type: "notFound" }
| { type: "error" }
> {
if (!(await this.posthog.isSourceLive("emex"))) {
this.logger.warn("EMEX disabled by kill switch (kill-source-emex)");
return { type: "notFound" };
}
try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const html = await this.fetchEmexHtml(vinUrl);
@@ -708,6 +714,10 @@ export class EmexService {
* Used after the user selects a vehicle from the multi-candidate modal.
*/
async decodeVinByIndex(vin: string, index: number): Promise<DecodedVehicle | null> {
if (!(await this.posthog.isSourceLive("emex"))) {
this.logger.warn("EMEX disabled by kill switch (kill-source-emex)");
return null;
}
try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const vinHtml = await this.fetchEmexHtml(vinUrl);