Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/client-config-from-datafile.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/vercel-flags-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
268 changes: 268 additions & 0 deletions packages/vercel-flags-core/src/black-box.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
35 changes: 30 additions & 5 deletions packages/vercel-flags-core/src/controller/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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;

@dferber90 dferber90 Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small fly-by nitpick: It's usually more future-proof to use an enum, just in case there are other modes in the future.

metrics: false can more easily turn into metrics: "config-reads-only" or any other modes we might want

so I'd keep "disable" out of the config name

}

/**
* Tracks a read operation for usage analytics.
* During build steps, only the first read is tracked.
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/vercel-flags-core/src/index.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading