From 2b65f82f28c7fa1ed527079a667ba9668ccdf7ca Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Wed, 8 Jul 2026 14:18:01 +0200 Subject: [PATCH] feat(flags-core): read client config (disableMetrics) from datafile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flags server can now deliver an optional top-level `config` field in the datafile payload: { "config": { "disableMetrics": true } } Since all sources (stream, poll, bundled, provided datafile, one-time fetch) emit raw DatafileInput, the config flows through every source automatically. The Controller reads it from the latest known data, so config updates arriving at runtime take effect for subsequent tracking. When the latest datafile sets config.disableMetrics: true, no usage metrics are ingested: trackRead/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). Absent config means all defaults — behavior unchanged. Co-Authored-By: Claude Fable 5 --- .changeset/client-config-from-datafile.md | 5 + packages/vercel-flags-core/CLAUDE.md | 10 + .../vercel-flags-core/src/black-box.test.ts | 268 ++++++++++++++++++ .../vercel-flags-core/src/controller/index.ts | 35 ++- .../vercel-flags-core/src/index.common.ts | 1 + packages/vercel-flags-core/src/types.ts | 21 ++ .../src/utils/usage-tracker.ts | 25 +- 7 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 .changeset/client-config-from-datafile.md 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;