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 diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 3cf87345..5c6c8254 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 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; delete process.env.NEXT_PHASE; @@ -124,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]; }); // --------------------------------------------------------------------------- @@ -199,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: 'test', - }, - }, - ]), - 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 () => { @@ -243,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: 'test', - }, - }, - ]), - 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 () => { @@ -317,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(); @@ -347,6 +308,7 @@ describe('Controller (black-box)', () => { await client.shutdown(); stream.close(); + cleanupCtx(); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenLastCalledWith( @@ -357,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', @@ -366,7 +329,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -425,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: 'test', - }, - }, - ]), - 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 () => { @@ -1170,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(); @@ -1217,6 +1157,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: after.getTime(), payload: { + invocationHost: 'example.com', configOrigin: 'embedded', cacheStatus: 'HIT', cacheAction: 'NONE', @@ -1226,18 +1167,15 @@ 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', }, ); + cleanupCtx(); }); it('should use bundled definitions when stream fails after init timeout (skip polling)', async () => { @@ -1247,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(); @@ -1296,6 +1235,7 @@ describe('Controller (black-box)', () => { type: 'FLAGS_CONFIG_READ', ts: after.getTime(), payload: { + invocationHost: 'example.com', configOrigin: 'embedded', cacheStatus: 'HIT', cacheAction: 'NONE', @@ -1305,7 +1245,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'offline', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -1313,6 +1253,7 @@ describe('Controller (black-box)', () => { method: 'POST', }, ); + cleanupCtx(); }); it('should never stream and poll simultaneously when stream is connected', async () => { @@ -2114,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(); @@ -2161,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', @@ -2170,7 +2113,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -2182,6 +2125,7 @@ describe('Controller (black-box)', () => { // still no streaming calls, as the count has not changed from above expect(fetchMock).toHaveBeenCalledTimes(2); + cleanupCtx(); }); }); @@ -2486,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(); @@ -2536,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', @@ -2545,12 +2491,13 @@ describe('Controller (black-box)', () => { configUpdatedAt: 2000, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), }, ); + cleanupCtx(); }); it('should skip stream data with equal configUpdatedAt', async () => { @@ -3224,7 +3171,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -3234,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) => { @@ -3273,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: 'test', - }, - }, - { - 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: 'test', - }, - }, - { - 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: 'test', - }, - }, - ]), - 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 () => { @@ -3408,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(); @@ -3468,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', @@ -3477,12 +3372,13 @@ describe('Controller (black-box)', () => { configUpdatedAt: 1, mode: 'offline', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), }, ); + cleanupCtx(); }); }); @@ -3681,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: 'test', - }, - }, - ]), - 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 }), @@ -3725,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: 'test', - }, - }, - ]), - 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(); @@ -3800,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', @@ -3809,7 +3659,7 @@ describe('Controller (black-box)', () => { configUpdatedAt: 5, mode: 'stream', revision: '1', - environment: 'test', + environment: 'production', }, }, ]), @@ -3819,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 }), @@ -3850,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: 'test', - }, - }, - ]), - 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/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..cb7bd6ab 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,19 @@ function jsonResponse( ); } +let cleanupContext: (() => void) | undefined; + +beforeEach(() => { + // 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(); - // Clean up environment variables - delete process.env.VERCEL_DEPLOYMENT_ID; - delete process.env.VERCEL_REGION; - delete process.env.DEBUG; + vi.unstubAllEnvs(); }); function createTracker(sdkKey = 'test-key') { @@ -60,6 +66,20 @@ describe('UsageTracker', () => { }); describe('trackRead', () => { + it('should skip when request context is unavailable', async () => { + // Remove the request context set up in beforeEach + cleanupContext?.(); + cleanupContext = undefined; + + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + tracker.trackRead(); + await tracker.flush(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('should batch events and send them after flush', async () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); @@ -77,8 +97,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 })); @@ -98,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 }>; @@ -189,7 +214,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' @@ -232,7 +257,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' @@ -420,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(); } diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index ff9bdc24..2be7411f 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -142,11 +142,12 @@ export class UsageTracker { try { 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', @@ -258,6 +259,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),