diff --git a/.changeset/client-config-from-datafile.md b/.changeset/client-config-from-datafile.md new file mode 100644 index 00000000..3f8302a4 --- /dev/null +++ b/.changeset/client-config-from-datafile.md @@ -0,0 +1,5 @@ +--- +'@vercel/flags-core': minor +--- + +Read client configuration from the datafile. The flags server can now send an optional top-level `config` field in the datafile payload (e.g. `{ "config": { "disableMetrics": true } }`). When the latest known datafile sets `config.disableMetrics: true`, the client stops recording usage events and never sends anything to the ingest endpoint. Config updates arriving at runtime (via stream or polling) take effect immediately for subsequent tracking. When `config` is absent, behavior is unchanged. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index e2adcaa4..9b0d1228 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -266,6 +266,16 @@ The Controller tags all data with its origin using `tagData(data, origin)` from - On flush failure, events are re-queued for retry with a max queue size of 500 events (oldest events are dropped when exceeded) - `flush()` directly flushes queued events even when no scheduled flush is pending, ensuring events are not lost during `shutdown()` +### Client Config from Datafile + +The datafile payload may carry an optional top-level `config` field (`ClientConfig` in `types.ts`) sent by the flags server, e.g. `{ "config": { "disableMetrics": true } }`. Since all sources (stream, poll, bundled, provided, one-time fetch) emit raw `DatafileInput`, the config flows through every source automatically. + +- The Controller reads the config from the latest known data (`this.data.config`) — a runtime update (e.g. a stream message) takes effect immediately for subsequent tracking +- `config.disableMetrics: true` suppresses ALL usage metrics: `trackRead` and `trackEvaluation` are gated in the Controller, and the `UsageTracker` additionally consults an `isDisabled` predicate before recording and before flushing (pending events are dropped instead of sent, so flushes never hit the network) +- `Controller.shutdown()` flushes the tracker before resetting `this.data` so the final flush still sees the latest config +- Absent `config` (or absent `disableMetrics`) means metrics are enabled — behavior unchanged +- Distinct from the local `disableMetrics` constructor option, which only disables evaluation metrics for that client instance + ### Client Management - Each client gets unique incrementing ID diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 281524a7..122d287d 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -4053,4 +4053,272 @@ describe('Controller (black-box)', () => { expectEvaluationOnlyIngest(); }); }); + + // --------------------------------------------------------------------------- + // Client config from datafile (config.disableMetrics) + // --------------------------------------------------------------------------- + describe('client config from datafile', () => { + it('should not ingest any metrics when the datafile sets config.disableMetrics', async () => { + const cleanupCtx = setRequestContext({ host: 'example.com' }); + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled({ config: { disableMetrics: true } }), + }); + + const result = await client.evaluate('flagA', undefined, undefined); + expect(result.value).toBe(true); + await client.bulkEvaluate([{ key: 'flagA' }], undefined); + await client.shutdown(); + + // Evaluation still works, but nothing is ever sent to the ingest endpoint + expect(fetchMock).not.toHaveBeenCalled(); + + cleanupCtx(); + }); + + it('should ingest metrics unchanged when config is present without disableMetrics', async () => { + const cleanupCtx = setRequestContext({ host: 'example.com' }); + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/ingest')) { + return Promise.resolve(new Response(null, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled({ config: {} }), + }); + + await client.evaluate('flagA', undefined, undefined); + 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: { + invocationHost: 'example.com', + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + mode: 'offline', + revision: '1', + environment: 'production', + }, + }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: undefined, + reason: 'paused', + evaluationCount: 1, + periodStartedAt: minuteBucketTs(date.getTime()), + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); + + cleanupCtx(); + }); + + it('should stop tracking when a stream update flips disableMetrics on', async () => { + const cleanupCtx = setRequestContext({ host: 'example.com' }); + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + + stream.push({ + type: 'datafile', + data: makeBundled({ configUpdatedAt: 1, revision: 1 }), + }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Tracked while metrics are enabled + const result1 = await client.evaluate('flagA', undefined, undefined); + expect(result1.value).toBe(true); + + // Server flips disableMetrics via a stream update + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 2, + revision: 2, + config: { disableMetrics: true }, + }), + }); + await vi.advanceTimersByTimeAsync(0); + + // Subsequent tracking is suppressed + const result2 = await client.evaluate('flagA', undefined, undefined); + expect(result2.value).toBe(true); + + stream.close(); + await client.shutdown(); + + // Only the stream connection ever hit the network — pending events are + // dropped instead of flushed once the latest datafile disables metrics. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/stream', + { + headers: streamRequestHeaders, + signal: expect.any(AbortSignal), + }, + ); + + cleanupCtx(); + }); + + it('should resume tracking when a stream update flips disableMetrics off', async () => { + const cleanupCtx = setRequestContext({ host: 'example.com' }); + const stream = createMockStream(); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/stream')) return stream.response; + if (url.includes('/v1/ingest')) { + return Promise.resolve(new Response(null, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + polling: false, + }); + const initPromise = client.initialize(); + + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 1, + revision: 1, + config: { disableMetrics: true }, + }), + }); + await vi.advanceTimersByTimeAsync(0); + await initPromise; + + // Not tracked while metrics are disabled + await client.evaluate('flagA', undefined, undefined); + + // Server re-enables metrics via a stream update + stream.push({ + type: 'datafile', + data: makeBundled({ + configUpdatedAt: 2, + revision: 2, + config: { disableMetrics: false }, + }), + }); + await vi.advanceTimersByTimeAsync(0); + + // Tracked again + await client.evaluate('flagA', undefined, undefined); + + stream.close(); + await client.shutdown(); + + // Stream connection + a single ingest flush containing only the + // events recorded after metrics were re-enabled. + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + invocationHost: 'example.com', + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'FOLLOWING', + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 2, + mode: 'stream', + revision: '2', + environment: 'production', + }, + }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: undefined, + reason: 'paused', + evaluationCount: 1, + periodStartedAt: minuteBucketTs(date.getTime()), + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); + + cleanupCtx(); + }); + + it('should not ingest any metrics when bundled definitions set config.disableMetrics', async () => { + vi.mocked(readBundledDefinitions).mockResolvedValue({ + state: 'ok', + definitions: makeBundled({ config: { disableMetrics: true } }), + }); + + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + buildStep: true, + fetch: fetchMock, + }); + + const result = await client.evaluate('flagA', undefined, undefined); + expect(result.value).toBe(true); + + await client.shutdown(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index 5f55c126..bdf04992 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -144,7 +144,10 @@ export class Controller implements ControllerInterface { this.data = tagData(this.options.datafile, 'provided'); } - this.usageTracker = new UsageTracker(this.options); + this.usageTracker = new UsageTracker( + this.options, + () => this.metricsDisabledByConfig, + ); } // Source event handlers (stored for cleanup) @@ -344,11 +347,14 @@ export class Controller implements ControllerInterface { this.unwireSourceEvents(); this.streamSource.stop(); this.pollingSource.stop(); + this.transition('shutdown'); + // Flush while `this.data` still reflects the latest known datafile, so + // server-driven client config (e.g. `config.disableMetrics`) applies to + // the final flush. + await this.usageTracker.shutdown(); this.data = this.options.datafile ? tagData(this.options.datafile, 'provided') : undefined; - this.transition('shutdown'); - await this.usageTracker.shutdown(); } /** @@ -771,6 +777,19 @@ export class Controller implements ControllerInterface { // Usage tracking // --------------------------------------------------------------------------- + /** + * Whether the latest known datafile disables metrics via its client config. + * + * The server can send `config.disableMetrics: true` in the datafile + * (delivered via stream, poll, one-time fetch, bundled definitions, or a + * provided datafile). While the latest known data says metrics are + * disabled, no usage events may be recorded or sent. Config arriving at + * runtime (e.g. a stream update) takes effect for subsequent tracking. + */ + private get metricsDisabledByConfig(): boolean { + return this.data?.config?.disableMetrics === true; + } + /** * Tracks a read operation for usage analytics. * During build steps, only the first read is tracked. @@ -781,7 +800,7 @@ export class Controller implements ControllerInterface { isFirstRead: boolean, source: Metrics['source'], ): void { - if (this.unauthorized) return; + if (this.unauthorized || this.metricsDisabledByConfig) return; if (this.options.buildStep && this.buildReadTracked) return; if (this.options.buildStep) this.buildReadTracked = true; @@ -821,7 +840,13 @@ export class Controller implements ControllerInterface { * Tracks a flag evaluation for usage analytics. */ trackEvaluation(options: TrackEvaluationOptions): void { - if (this.unauthorized || this.options.disableMetrics) return; + if ( + this.unauthorized || + this.options.disableMetrics || + this.metricsDisabledByConfig + ) { + return; + } this.usageTracker.trackEvaluation({ ...options, diff --git a/packages/vercel-flags-core/src/index.common.ts b/packages/vercel-flags-core/src/index.common.ts index a8834192..3f51fdd5 100644 --- a/packages/vercel-flags-core/src/index.common.ts +++ b/packages/vercel-flags-core/src/index.common.ts @@ -14,6 +14,7 @@ export { evaluate } from './evaluate'; export type { CreateClientOptions } from './index.make'; export { type BundledDefinitions, + type ClientConfig, type Datafile, type DatafileInput, type EvaluationParams, diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 6e7fbe4d..24a07a56 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -16,6 +16,22 @@ export type PollingOptions = { initTimeoutMs: number; }; +/** + * Client configuration delivered by the flags server as part of the datafile. + * + * All fields are optional; an absent `config` (or absent field) means the + * default behavior applies. This shape is extensible — future client + * configuration options will be added here. + */ +export type ClientConfig = { + /** + * When true, the client must not ingest any usage metrics + * (no read or evaluation events are recorded or sent). + * @default false + */ + disableMetrics?: boolean; +}; + /** Input type for creating a datafile (without metrics) */ export type DatafileInput = Packed.Data & { /** @@ -32,6 +48,11 @@ export type DatafileInput = Packed.Data & { configUpdatedAt?: number | string; /** Version number of the data */ revision?: number; + /** + * Optional client configuration sent by the flags server. + * Absent means all defaults (e.g. metrics enabled). + */ + config?: ClientConfig; }; /** Datafile with metrics attached (returned by the client) */ diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index ab0e78a0..e0d4a5b5 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -26,8 +26,18 @@ export class UsageTracker { private readEvents: FlagsConfigReadEvent[] = []; private evaluationEvents = new Map(); - constructor(options: IngestOptions) { + private isDisabled: () => boolean; + + /** + * @param options ingest endpoint options + * @param isDisabled predicate consulted before recording and before sending + * events. While it returns true no events are accumulated, and pending + * events are dropped instead of sent (flushes never hit the network). + * Used for server-driven client config (`config.disableMetrics`). + */ + constructor(options: IngestOptions, isDisabled: () => boolean = () => false) { this.options = options; + this.isDisabled = isDisabled; this.scheduler = new Scheduler((reason) => this.flushEvents(reason)); } @@ -49,6 +59,8 @@ export class UsageTracker { */ trackRead(options?: TrackReadOptions): void { try { + if (this.isDisabled()) return; + const { ctx, headers } = getRequestContext(); // Skip if request context can't be inferred @@ -72,6 +84,8 @@ export class UsageTracker { */ trackEvaluation(options: TrackEvaluationOptions): void { try { + if (this.isDisabled()) return; + const bucketedOptions = { ...options, bucketTs: minuteBucketTs(), @@ -103,6 +117,15 @@ export class UsageTracker { * Send all events to the ingest service */ private async flushEvents(flushReason: FlushReason) { + // When metrics are disabled (e.g. the server sent + // `config.disableMetrics: true`), drop any pending events instead of + // sending them so flushes never hit the network. + if (this.isDisabled()) { + this.readEvents = []; + this.evaluationEvents.clear(); + return; + } + const events = [...this.readEvents, ...this.evaluationEvents.values()]; if (events.length === 0) return;