From f7609f300230423ccb0983164df255d23004bc19 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 12 Mar 2026 15:50:12 +0200 Subject: [PATCH 1/4] skip sending config read for dev and custom envs --- .../vercel-flags-core/src/black-box.test.ts | 45 +++++++++--------- .../src/controller/fetch-datafile.ts | 3 ++ .../src/controller/stream-connection.ts | 4 ++ .../src/utils/usage-tracker.test.ts | 47 +++++++++++++++++-- .../src/utils/usage-tracker.ts | 9 ++++ 5 files changed, 81 insertions(+), 27 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 3cf87345..8c713dfe 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -83,17 +83,20 @@ const ingestRequestHeaders = Object.freeze({ Authorization: 'Bearer vf_fake', 'Content-Type': 'application/json', 'User-Agent': `VercelFlagsCore/${version}`, + 'X-Vercel-Env': 'production', }); const streamRequestHeaders = Object.freeze({ Authorization: 'Bearer vf_fake', 'User-Agent': `VercelFlagsCore/${version}`, 'X-Retry-Attempt': '0', + 'X-Vercel-Env': 'production', }); const datafileRequestHeaders = Object.freeze({ Authorization: 'Bearer vf_fake', 'User-Agent': `VercelFlagsCore/${version}`, + 'X-Vercel-Env': 'production', }); const originalEnv = { ...process.env }; @@ -115,6 +118,8 @@ describe('Controller (black-box)', () => { if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); return undefined as unknown as Promise; }); + // Set VERCEL_ENV so usage tracking is active (it's skipped when undefined or 'development') + process.env.VERCEL_ENV = 'production'; // Reset env vars that affect build step detection delete process.env.CI; delete process.env.NEXT_PHASE; @@ -217,7 +222,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'build', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -261,7 +266,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'build', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -366,7 +371,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -443,7 +448,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'build', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -1226,15 +1231,11 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'offline', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), - headers: { - Authorization: 'Bearer vf_fake', - 'Content-Type': 'application/json', - 'User-Agent': `VercelFlagsCore/${version}`, - }, + headers: ingestRequestHeaders, method: 'POST', }, ); @@ -1305,7 +1306,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'offline', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -2170,7 +2171,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -2545,7 +2546,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 2000, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -3224,7 +3225,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -3291,7 +3292,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, { @@ -3306,7 +3307,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, { @@ -3321,7 +3322,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -3477,7 +3478,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'offline', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -3699,7 +3700,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 2, mode: 'build', revision: '2', - environment: 'test', + environment: 'production', }, }, ]), @@ -3743,7 +3744,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'build', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -3809,7 +3810,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 5, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -3868,7 +3869,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 2, mode: 'build', revision: '2', - environment: 'test', + environment: 'production', }, }, ]), diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts index 0ff0aa80..b63b01b6 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -33,6 +33,9 @@ export async function fetchDatafile(options: { headers: { Authorization: `Bearer ${options.sdkKey}`, 'User-Agent': `VercelFlagsCore/${version}`, + ...(process.env.VERCEL_ENV + ? { 'X-Vercel-Env': process.env.VERCEL_ENV } + : null), }, signal: controller.signal, }); diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index 2f533052..fcf4c156 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -119,6 +119,10 @@ export async function connectStream( 'User-Agent': `VercelFlagsCore/${version}`, 'X-Retry-Attempt': String(retryCount), }; + const vercelEnv = process.env.VERCEL_ENV; + if (vercelEnv) { + headers['X-Vercel-Env'] = vercelEnv; + } const revision = config.revision?.(); if (revision !== undefined) { headers['X-Revision'] = String(revision); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 0becca06..fc52156b 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { setRequestContext } from '../test-utils'; import { type FlagsConfigReadEvent, UsageTracker } from './usage-tracker'; @@ -24,13 +24,17 @@ function jsonResponse( ); } +const originalEnv = { ...process.env }; + +beforeEach(() => { + // Set VERCEL_ENV so trackRead doesn't skip (it's skipped when undefined or 'development') + process.env.VERCEL_ENV = 'production'; +}); + afterEach(() => { fetchMock.mockReset(); vi.restoreAllMocks(); - // Clean up environment variables - delete process.env.VERCEL_DEPLOYMENT_ID; - delete process.env.VERCEL_REGION; - delete process.env.DEBUG; + process.env = { ...originalEnv }; }); function createTracker(sdkKey = 'test-key') { @@ -60,6 +64,39 @@ describe('UsageTracker', () => { }); describe('trackRead', () => { + it('should skip when VERCEL_ENV is undefined', async () => { + delete process.env.VERCEL_ENV; + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + tracker.trackRead(); + await tracker.flush(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should skip when VERCEL_ENV is development', async () => { + process.env.VERCEL_ENV = 'development'; + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + tracker.trackRead(); + await tracker.flush(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should send when VERCEL_ENV is preview', async () => { + process.env.VERCEL_ENV = 'preview'; + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + tracker.trackRead(); + await tracker.flush(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('should batch events and send them after flush', async () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index ff9bdc24..26917fa0 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -140,6 +140,12 @@ export class UsageTracker { */ trackRead(options?: TrackReadOptions): void { try { + // Skip config read reporting outside of Vercel deployments. + // VERCEL_ENV is only set on Vercel; in local dev or custom backends + // the metric would be inaccurate (no request-context deduplication). + const vercelEnv = process.env.VERCEL_ENV; + if (!vercelEnv || vercelEnv === 'development') return; + const { ctx, headers } = getRequestContext(); // Skip if we've already tracked this request @@ -258,6 +264,9 @@ export class UsageTracker { 'Content-Type': 'application/json', Authorization: `Bearer ${this.options.sdkKey}`, 'User-Agent': `VercelFlagsCore/${version}`, + ...(process.env.VERCEL_ENV + ? { 'X-Vercel-Env': process.env.VERCEL_ENV } + : null), ...(isDebugMode ? { 'x-vercel-debug-ingest': '1' } : null), }, body: JSON.stringify(eventsToSend), From dcdbdc8cb1b46a1740a2cc6d3d218121c09fd975 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 12 Mar 2026 15:50:42 +0200 Subject: [PATCH 2/4] add changeset --- .changeset/warm-seals-peel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/warm-seals-peel.md diff --git a/.changeset/warm-seals-peel.md b/.changeset/warm-seals-peel.md new file mode 100644 index 00000000..d6c213a9 --- /dev/null +++ b/.changeset/warm-seals-peel.md @@ -0,0 +1,5 @@ +--- +"@vercel/flags-core": patch +--- + +Skip sending config read events for dev and custom backends From 831a1fecd035de470e5344b60e1519b5ad0b4340 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 12 Mar 2026 16:04:04 +0200 Subject: [PATCH 3/4] clean up --- .../src/utils/usage-tracker.test.ts | 20 +++++++++---------- .../src/utils/usage-tracker.ts | 15 +++++--------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index fc52156b..180ca8de 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -24,17 +24,15 @@ function jsonResponse( ); } -const originalEnv = { ...process.env }; - beforeEach(() => { // Set VERCEL_ENV so trackRead doesn't skip (it's skipped when undefined or 'development') - process.env.VERCEL_ENV = 'production'; + vi.stubEnv('VERCEL_ENV', 'production'); }); afterEach(() => { fetchMock.mockReset(); vi.restoreAllMocks(); - process.env = { ...originalEnv }; + vi.unstubAllEnvs(); }); function createTracker(sdkKey = 'test-key') { @@ -65,7 +63,7 @@ describe('UsageTracker', () => { describe('trackRead', () => { it('should skip when VERCEL_ENV is undefined', async () => { - delete process.env.VERCEL_ENV; + vi.unstubAllEnvs(); fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = createTracker(); @@ -76,7 +74,7 @@ describe('UsageTracker', () => { }); it('should skip when VERCEL_ENV is development', async () => { - process.env.VERCEL_ENV = 'development'; + vi.stubEnv('VERCEL_ENV', 'development'); fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = createTracker(); @@ -87,7 +85,7 @@ describe('UsageTracker', () => { }); it('should send when VERCEL_ENV is preview', async () => { - process.env.VERCEL_ENV = 'preview'; + vi.stubEnv('VERCEL_ENV', 'preview'); fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = createTracker(); @@ -114,8 +112,8 @@ describe('UsageTracker', () => { }); it('should include deployment ID and region from environment', async () => { - process.env.VERCEL_DEPLOYMENT_ID = 'dpl_123'; - process.env.VERCEL_REGION = 'iad1'; + vi.stubEnv('VERCEL_DEPLOYMENT_ID', 'dpl_123'); + vi.stubEnv('VERCEL_REGION', 'iad1'); fetchMock.mockImplementation(() => jsonResponse({ ok: true })); @@ -226,7 +224,7 @@ describe('UsageTracker', () => { }); it('should send x-vercel-debug-ingest header in debug mode', async () => { - process.env.DEBUG = '@vercel/flags-core'; + vi.stubEnv('DEBUG', '@vercel/flags-core'); vi.resetModules(); const { UsageTracker: FreshUsageTracker } = await import( './usage-tracker' @@ -269,7 +267,7 @@ describe('UsageTracker', () => { }); it('should log ingest response in debug mode', async () => { - process.env.DEBUG = '@vercel/flags-core'; + vi.stubEnv('DEBUG', '@vercel/flags-core'); vi.resetModules(); const { UsageTracker: FreshUsageTracker } = await import( './usage-tracker' diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index 26917fa0..2be7411f 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -140,19 +140,14 @@ export class UsageTracker { */ trackRead(options?: TrackReadOptions): void { try { - // Skip config read reporting outside of Vercel deployments. - // VERCEL_ENV is only set on Vercel; in local dev or custom backends - // the metric would be inaccurate (no request-context deduplication). - const vercelEnv = process.env.VERCEL_ENV; - if (!vercelEnv || vercelEnv === 'development') return; - const { ctx, headers } = getRequestContext(); + // Skip if request context can't be inferred + if (!ctx) return; + // Skip if we've already tracked this request - if (ctx) { - if (this.trackedRequests.has(ctx)) return; - this.trackedRequests.add(ctx); - } + if (this.trackedRequests.has(ctx)) return; + this.trackedRequests.add(ctx); const event: FlagsConfigReadEvent = { type: 'FLAGS_CONFIG_READ', From a084c0b92d5109d4ad843487e658c03ae5b87bc3 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 12 Mar 2026 17:03:54 +0200 Subject: [PATCH 4/4] wip --- .../vercel-flags-core/src/black-box.test.ts | 258 +++--------------- .../src/utils/usage-tracker.test.ts | 55 ++-- 2 files changed, 67 insertions(+), 246 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 8c713dfe..5c6c8254 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -118,7 +118,7 @@ describe('Controller (black-box)', () => { if (url.includes('/v1/ingest')) return Promise.resolve(new Response()); return undefined as unknown as Promise; }); - // Set VERCEL_ENV so usage tracking is active (it's skipped when undefined or 'development') + // Set VERCEL_ENV so X-Vercel-Env headers are sent in requests process.env.VERCEL_ENV = 'production'; // Reset env vars that affect build step detection delete process.env.CI; @@ -129,6 +129,9 @@ describe('Controller (black-box)', () => { vi.restoreAllMocks(); vi.useRealTimers(); process.env = { ...originalEnv }; + // Clean up any request context set during the test + const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); + delete (globalThis as any)[SYMBOL_FOR_REQ_CONTEXT]; }); // --------------------------------------------------------------------------- @@ -204,32 +207,8 @@ describe('Controller (black-box)', () => { expect(fetchMock).not.toHaveBeenCalled(); await client.shutdown(); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenLastCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: JSON.stringify([ - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'embedded', - cacheStatus: 'HIT', - cacheAction: 'NONE', - cacheIsFirstRead: true, - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - mode: 'build', - revision: '1', - environment: 'production', - }, - }, - ]), - headers: ingestRequestHeaders, - method: 'POST', - }, - ); + // No ingest call: trackRead skips when request context is unavailable (build step has no request context) + expect(fetchMock).not.toHaveBeenCalled(); }); it('should detect build step when NEXT_PHASE=phase-production-build', async () => { @@ -248,32 +227,8 @@ describe('Controller (black-box)', () => { expect(fetchMock).not.toHaveBeenCalled(); await client.shutdown(); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenLastCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: JSON.stringify([ - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'embedded', - cacheStatus: 'HIT', - cacheAction: 'NONE', - cacheIsFirstRead: true, - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - mode: 'build', - revision: '1', - environment: 'production', - }, - }, - ]), - headers: ingestRequestHeaders, - method: 'POST', - }, - ); + // No ingest call: trackRead skips when request context is unavailable + expect(fetchMock).not.toHaveBeenCalled(); }); it('should NOT detect build step when neither CI nor NEXT_PHASE is set', async () => { @@ -322,6 +277,7 @@ describe('Controller (black-box)', () => { process.env.CI = '1'; // Would normally trigger build step const stream = createMockStream(); + const cleanupCtx = setRequestContext({ host: 'example.com' }); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); @@ -352,6 +308,7 @@ describe('Controller (black-box)', () => { await client.shutdown(); stream.close(); + cleanupCtx(); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenLastCalledWith( @@ -362,6 +319,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: date.getTime(), payload: { + invocationHost: 'example.com', configOrigin: 'in-memory', cacheStatus: 'HIT', cacheAction: 'FOLLOWING', @@ -430,32 +388,8 @@ describe('Controller (black-box)', () => { await client.shutdown(); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock).toHaveBeenLastCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: JSON.stringify([ - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'in-memory', - cacheStatus: 'HIT', - cacheAction: 'NONE', - cacheIsFirstRead: true, - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - mode: 'build', - revision: '1', - environment: 'production', - }, - }, - ]), - headers: ingestRequestHeaders, - method: 'POST', - }, - ); + // No ingest call: trackRead skips when request context is unavailable (build step has no request context) + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should throw when bundled definitions missing and fetch fails during build (no defaultValue)', async () => { @@ -1175,6 +1109,7 @@ describe('Controller (black-box)', () => { }); let pollCount = 0; + const cleanupCtx = setRequestContext({ host: 'example.com' }); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); @@ -1222,6 +1157,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: after.getTime(), payload: { + invocationHost: 'example.com', configOrigin: 'embedded', cacheStatus: 'HIT', cacheAction: 'NONE', @@ -1239,6 +1175,7 @@ describe('Controller (black-box)', () => { method: 'POST', }, ); + cleanupCtx(); }); it('should use bundled definitions when stream fails after init timeout (skip polling)', async () => { @@ -1248,6 +1185,7 @@ describe('Controller (black-box)', () => { }); let pollCount = 0; + const cleanupCtx = setRequestContext({ host: 'example.com' }); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); @@ -1297,6 +1235,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: after.getTime(), payload: { + invocationHost: 'example.com', configOrigin: 'embedded', cacheStatus: 'HIT', cacheAction: 'NONE', @@ -1314,6 +1253,7 @@ describe('Controller (black-box)', () => { method: 'POST', }, ); + cleanupCtx(); }); it('should never stream and poll simultaneously when stream is connected', async () => { @@ -2115,6 +2055,7 @@ describe('Controller (black-box)', () => { it('should cleanly shut down mid-stream', async () => { const stream = createMockStream(); + const cleanupCtx = setRequestContext({ host: 'example.com' }); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); @@ -2162,6 +2103,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: date.getTime(), payload: { + invocationHost: 'example.com', configOrigin: 'in-memory', cacheStatus: 'HIT', cacheAction: 'FOLLOWING', @@ -2183,6 +2125,7 @@ describe('Controller (black-box)', () => { // still no streaming calls, as the count has not changed from above expect(fetchMock).toHaveBeenCalledTimes(2); + cleanupCtx(); }); }); @@ -2487,6 +2430,7 @@ describe('Controller (black-box)', () => { }); const stream = createMockStream(); + const cleanupCtx = setRequestContext({ host: 'example.com' }); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); @@ -2537,6 +2481,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: date.getTime() + 60, payload: { + invocationHost: 'example.com', configOrigin: 'in-memory', cacheStatus: 'HIT', cacheAction: 'FOLLOWING', @@ -2552,6 +2497,7 @@ describe('Controller (black-box)', () => { ]), }, ); + cleanupCtx(); }); it('should skip stream data with equal configUpdatedAt', async () => { @@ -3235,7 +3181,7 @@ describe('Controller (black-box)', () => { ); }); - it('should deduplicate concurrent evaluate() calls that trigger initialize, and track each read individually when request context is missing', async () => { + it('should skip tracking when request context is missing', async () => { const stream = createMockStream(); fetchMock.mockImplementation((input) => { @@ -3274,62 +3220,8 @@ describe('Controller (black-box)', () => { stream.close(); await client.shutdown(); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock).toHaveBeenLastCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: JSON.stringify([ - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'in-memory', - cacheStatus: 'HIT', - cacheAction: 'FOLLOWING', - cacheIsFirstRead: true, - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - mode: 'stream', - revision: '1', - environment: 'production', - }, - }, - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'in-memory', - cacheStatus: 'HIT', - cacheAction: 'FOLLOWING', - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - mode: 'stream', - revision: '1', - environment: 'production', - }, - }, - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'in-memory', - cacheStatus: 'HIT', - cacheAction: 'FOLLOWING', - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - mode: 'stream', - revision: '1', - environment: 'production', - }, - }, - ]), - headers: ingestRequestHeaders, - method: 'POST', - }, - ); + // No ingest call: trackRead skips when request context is unavailable + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should start only one retry loop when concurrent evaluate() calls hit a failing stream', async () => { @@ -3409,6 +3301,7 @@ describe('Controller (black-box)', () => { }); let fetchCallCount = 0; + const cleanupCtx = setRequestContext({ host: 'example.com' }); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); @@ -3469,6 +3362,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: date.getTime(), payload: { + invocationHost: 'example.com', configOrigin: 'in-memory', cacheStatus: 'HIT', cacheAction: 'NONE', @@ -3484,6 +3378,7 @@ describe('Controller (black-box)', () => { ]), }, ); + cleanupCtx(); }); }); @@ -3682,35 +3577,11 @@ describe('Controller (black-box)', () => { await client.shutdown(); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: JSON.stringify([ - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'in-memory', - cacheStatus: 'HIT', - cacheAction: 'NONE', - cacheIsFirstRead: true, - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 2, - mode: 'build', - revision: '2', - environment: 'production', - }, - }, - ]), - headers: ingestRequestHeaders, - method: 'POST', - }, - ); + // No ingest call: trackRead skips when request context is unavailable (build step has no request context) + expect(fetchMock).not.toHaveBeenCalled(); }); - it('should only track one FLAGS_CONFIG_READ during build step', async () => { + it('should not track FLAGS_CONFIG_READ during build step (no request context)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled({ configUpdatedAt: 1 }), @@ -3726,36 +3597,13 @@ describe('Controller (black-box)', () => { await client.evaluate('flagA'); await client.shutdown(); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: JSON.stringify([ - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'embedded', - cacheStatus: 'HIT', - cacheAction: 'NONE', - cacheIsFirstRead: true, - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 1, - mode: 'build', - revision: '1', - environment: 'production', - }, - }, - ]), - headers: ingestRequestHeaders, - method: 'POST', - }, - ); + // No ingest call: trackRead skips when request context is unavailable + expect(fetchMock).not.toHaveBeenCalled(); }); it('should report FLAGS_CONFIG_READ with FOLLOWING cacheAction when streaming', async () => { const stream = createMockStream(); + const cleanupCtx = setRequestContext({ host: 'example.com' }); fetchMock.mockImplementation((input) => { const url = typeof input === 'string' ? input : input.toString(); @@ -3801,6 +3649,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: date.getTime(), payload: { + invocationHost: 'example.com', configOrigin: 'in-memory', cacheStatus: 'HIT', cacheAction: 'FOLLOWING', @@ -3820,9 +3669,10 @@ describe('Controller (black-box)', () => { ); stream.close(); + cleanupCtx(); }); - it('should report FLAGS_CONFIG_READ when using bundled definitions in build step', async () => { + it('should not report FLAGS_CONFIG_READ during build step (no request context)', async () => { vi.mocked(readBundledDefinitions).mockResolvedValue({ state: 'ok', definitions: makeBundled({ configUpdatedAt: 2, revision: 2 }), @@ -3851,32 +3701,8 @@ describe('Controller (black-box)', () => { await client.shutdown(); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledWith( - 'https://flags.vercel.com/v1/ingest', - { - body: JSON.stringify([ - { - type: 'FLAGS_CONFIG_READ', - ts: date.getTime(), - payload: { - configOrigin: 'embedded', - cacheStatus: 'HIT', - cacheAction: 'NONE', - cacheIsFirstRead: true, - cacheIsBlocking: false, - duration: 0, - configUpdatedAt: 2, - mode: 'build', - revision: '2', - environment: 'production', - }, - }, - ]), - headers: ingestRequestHeaders, - method: 'POST', - }, - ); + // No ingest call: trackRead skips when request context is unavailable + expect(fetchMock).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 180ca8de..cb7bd6ab 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -24,12 +24,16 @@ function jsonResponse( ); } +let cleanupContext: (() => void) | undefined; + beforeEach(() => { - // Set VERCEL_ENV so trackRead doesn't skip (it's skipped when undefined or 'development') - vi.stubEnv('VERCEL_ENV', 'production'); + // Set up request context so trackRead doesn't skip (it's skipped when ctx is unavailable) + cleanupContext = setRequestContext({ host: 'example.com' }); }); afterEach(() => { + cleanupContext?.(); + cleanupContext = undefined; fetchMock.mockReset(); vi.restoreAllMocks(); vi.unstubAllEnvs(); @@ -62,19 +66,11 @@ describe('UsageTracker', () => { }); describe('trackRead', () => { - it('should skip when VERCEL_ENV is undefined', async () => { - vi.unstubAllEnvs(); - fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + it('should skip when request context is unavailable', async () => { + // Remove the request context set up in beforeEach + cleanupContext?.(); + cleanupContext = undefined; - const tracker = createTracker(); - tracker.trackRead(); - await tracker.flush(); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('should skip when VERCEL_ENV is development', async () => { - vi.stubEnv('VERCEL_ENV', 'development'); fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = createTracker(); @@ -84,17 +80,6 @@ describe('UsageTracker', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('should send when VERCEL_ENV is preview', async () => { - vi.stubEnv('VERCEL_ENV', 'preview'); - fetchMock.mockImplementation(() => jsonResponse({ ok: true })); - - const tracker = createTracker(); - tracker.trackRead(); - await tracker.flush(); - - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - it('should batch events and send them after flush', async () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); @@ -133,10 +118,15 @@ describe('UsageTracker', () => { const tracker = createTracker(); - // Track multiple reads (without request context, so they won't be deduplicated) - tracker.trackRead(); - tracker.trackRead(); - tracker.trackRead(); + // Track multiple reads with different request contexts so they won't be deduplicated + for (let i = 0; i < 3; i++) { + cleanupContext?.(); + cleanupContext = setRequestContext({ + host: 'example.com', + 'x-vercel-id': `req-${i}`, + }); + tracker.trackRead(); + } await tracker.flush(); const events = getBody() as Array<{ type: string }>; @@ -455,8 +445,13 @@ describe('UsageTracker', () => { const tracker = createTracker(); - // Track 50 events (without request context to avoid deduplication) + // Track 50 events with different request contexts to avoid deduplication for (let i = 0; i < 50; i++) { + cleanupContext?.(); + cleanupContext = setRequestContext({ + host: 'example.com', + 'x-vercel-id': `req-${i}`, + }); tracker.trackRead(); }