Skip to content

Commit 4bdc9b1

Browse files
fix: align experimentation with new ExP contract
Use the approved assignments endpoint and reserve DevDeviceId as the platform-owned identity while keeping live requests disabled until VS Code exposes an approved provider. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 81f45c4 commit 4bdc9b1

9 files changed

Lines changed: 222 additions & 150 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,10 @@ Run unit tests with the different configurations in the "Run and Debug" panel
8585

8686
See [Experimentation infrastructure](./docs/experimentation.md) for the internal TAS
8787
service, publisher configuration, lifecycle and consent behavior, deterministic tests,
88-
and the baseline measurement inventory. Live experimentation remains unconfigured until
89-
the endpoint and identity contract have been approved; this infrastructure does not
90-
enable a feature or publish an experimental setting.
88+
and the baseline measurement inventory. The new assignments endpoint is fixed by the
89+
platform, but live experimentation remains unconfigured until VS Code exposes an approved
90+
DevDeviceId provider. This infrastructure does not enable a feature or publish an
91+
experimental setting.
9192

9293
## Contributor License Agreement (CLA)
9394

‎docs/experimentation.md‎

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,39 @@
22

33
The extension owns one internal TAS service per activation. This infrastructure
44
supports general experimentation and measurement without enabling product features.
5-
Live TAS configuration remains absent until the platform owners approve the endpoint
6-
and identity contract.
5+
The new assignments endpoint and parameter name are platform-owned. Live TAS
6+
configuration remains absent until VS Code provides an approved DevDeviceId API to
7+
the extension.
8+
9+
## Settings rollout versus extension attribution
10+
11+
An `onExP` settings rollout does not require this service merely to apply its
12+
treatment. Declare the setting with a safe package default and the `onExP` tag,
13+
configure `config.<full-setting-id>` in ExP, and read the effective value through
14+
`workspace.getConfiguration()`. VS Code core retrieves the assignment and applies
15+
the treatment as a default; explicit user and workspace values still take
16+
precedence. Do not add a second TAS treatment gate for the same setting.
17+
18+
Use this service when an extension-owned experiment needs treatment variables, or
19+
when Python Environments telemetry must carry the matching assignment context for
20+
an experiment scorecard.
721

822
## Publisher configuration
923

1024
`initializeExperimentation()` reads an optional top-level `experimentation` object
1125
from the installed extension's `package.json`. This is publisher-owned metadata,
12-
**not** a VS Code setting: a workspace must not be able to redirect requests that
13-
contain an identifier.
26+
**not** a VS Code setting. The endpoint and identity parameter cannot be overridden
27+
by publisher or workspace configuration.
1428

15-
The following is a schema illustration, not deployable configuration. Replace the
16-
placeholders only with reviewed values; do not copy names from legacy `X-*` headers.
29+
The following is a schema illustration, not deployable configuration. The manifest
30+
entry must remain absent until the DevDeviceId provider is approved and wired.
31+
Do not copy names from legacy `X-*` headers.
1732

1833
```json
1934
{
2035
"experimentation": {
21-
"assignmentsEndpoint": "https://<approved-host>/api/v1/assignments",
2236
"targetPopulation": "public",
23-
"identityParameter": "<approved-identity-parameter>",
2437
"assignmentParameters": {
25-
"<approved-identity-parameter>": "machineId",
2638
"<approved-extension-version-parameter>": "extensionVersion",
2739
"<approved-language-parameter>": "language"
2840
}
@@ -33,19 +45,30 @@ placeholders only with reviewed values; do not copy names from legacy `X-*` head
3345
Supported populations are `public`, `insider`, `internal`, and `team`. Select one
3446
explicitly with the owners; an `internal` value is not authentication or proof of
3547
employee status. Version and language bindings are optional. The identity binding
36-
is mandatory and must occur exactly once. Generic SDK parameters cannot be overridden.
48+
`"devdeviceid": "devDeviceId"` is added by the platform configuration and cannot be
49+
overridden. Generic SDK parameters cannot be overridden.
50+
51+
The approved full endpoint is:
52+
53+
```text
54+
https://exp.individual.githubcopilot.com/api/v1/assignments
55+
```
56+
57+
The host is currently fixed rather than taken from a Copilot token. Business and
58+
Enterprise networks may block the Individual endpoint; those failures must remain
59+
nonfatal and their telemetry unattributed. Validate attribution coverage before
60+
using extension events in a scorecard.
3761

38-
The currently implemented identity source is VS Code's public `env.machineId` API.
39-
It must only be used when that is the approved randomization identity. **MachineId
40-
is not DevDeviceId.** If onboarding requires DevDeviceId or another identity, add and
41-
review its provider first; unsupported sources are rejected, not silently substituted.
42-
Ensure the analysis identity, and any future VS Code core setting experiment's
43-
identity, agrees with this contract.
62+
The public extension API does not currently expose DevDeviceId. The service accepts
63+
an injected provider so the approved API can be connected without falling back to
64+
MachineId. Until that provider is available, configured initialization fails closed
65+
and makes no TAS request. The legacy MachineId read elsewhere in the service only
66+
namespaces cache data for the SDK's inherited legacy request; it is not the new
67+
assignments identity.
4468

4569
Absent configuration reports `notConfigured` and makes no TAS requests. Invalid
4670
configuration reports an error and also makes no requests; it does not silently
47-
fall back to a legacy-only integration. No endpoint, identity value, or mapping is
48-
accepted from workspace configuration.
71+
fall back to a legacy-only integration.
4972

5073
## Service lifecycle and queries
5174

@@ -82,10 +105,11 @@ snapshot queries, not refresh requests.
82105

83106
Cache data lives in `context.globalState`, namespaced by the approved configuration,
84107
extension version, resolved assignment parameters, and the SDK's built-in targeting
85-
values: VS Code version, application name, language, and legacy MachineId. The namespace
86-
is hashed; identifiers and endpoints are not emitted in diagnostics. This prevents a
87-
snapshot from being reused after its population, endpoint, version, identity, or audience
88-
context changes. Malformed cache data is ignored with a warning.
108+
values: VS Code version, application name, language, and legacy MachineId. The new
109+
assignments identity is DevDeviceId. The namespace is hashed; identifiers and
110+
endpoints are not emitted in diagnostics. This prevents a snapshot from being reused
111+
after its population, endpoint, version, identity, or audience context changes.
112+
Malformed cache data is ignored with a warning.
89113

90114
Revoking telemetry consent disposes the SDK, aborts outstanding requests, clears
91115
shared attribution, and makes queries use defaults. Re-enabling consent creates a
@@ -98,7 +122,9 @@ New-endpoint variables take precedence when both return the same name. The commo
98122
HTTPS transport supplies cancellation, a ten-second request deadline, and a two-MiB
99123
response cap to both endpoints. It uses Node HTTPS like the SDK, retaining the
100124
extension host's HTTP hooks; proxy behavior must still be verified in the deployment
101-
environments. There is no insecure TLS or redirect fallback.
125+
environments. There is no insecure TLS or redirect fallback. New experiments must
126+
use the shared GitHub/DevDiv workspace and the assignments POST; the inherited
127+
legacy request is SDK behavior and is not a supported fallback for this extension.
102128

103129
`tas-client` requires Node 22. TypeScript 5.8 or newer is needed to type-check the
104130
current wrapper's CommonJS-to-ESM declarations without disabling library checking.
@@ -177,13 +203,15 @@ Unit tests use a fake SDK for lifecycle cases. A separate contract test loads th
177203
installed SDK with a fake transport to verify dual requests, assignment merging,
178204
bare variable names, and shared attribution without contacting TAS.
179205

180-
Before live use, confirm the endpoint, identity names/source, audience/population,
181-
ExP workspace, access, and scorecard with the VS Code experimentation owners. Obtain the integration and
182-
metrics reviews described in the onboarding guidance. An A/A can validate allocation,
183-
attribution, data quality and baseline stability without exposing a new setting or
184-
changing product behavior. A real `tas-call` with `callType = assignments` and
185-
`outcome = Success`, a known new-endpoint assignment, and tagged subsequent telemetry
186-
must all agree; a cached value alone is not proof that onboarding works.
206+
Before live use, obtain DevDeviceId access and confirm the audience/population, shared
207+
GitHub/DevDiv workspace group, access, and scorecard with the experimentation owners.
208+
The old workspace and old TAS endpoint are not supported for new experiments. Obtain
209+
the integration and metrics reviews described in the onboarding guidance. An A/A can
210+
validate allocation, attribution, data quality and baseline stability without exposing
211+
a new setting or changing product behavior. A real `tas-call` with
212+
`callType = assignments` and `outcome = Success`, a known new-endpoint assignment,
213+
and tagged subsequent telemetry must all agree; a cached value alone is not proof
214+
that onboarding works.
187215

188216
Some older checklists still require `vscode.abexp.features`; the current SDK no
189217
longer maintains it. Use the current assignment-context guidance instead. The

‎src/common/env.apis.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function onDidChangeTelemetryEnabled(listener: (enabled: boolean) => void
1919
return env.onDidChangeTelemetryEnabled(listener);
2020
}
2121

22-
/** Read the machine identifier for an approved identity binding. */
22+
/** Read the legacy MachineId targeting value that vscode-tas-client adds automatically. */
2323
export function getMachineId(): string {
2424
return env.machineId;
2525
}

‎src/common/experimentation/configuration.ts‎

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
// Licensed under the MIT License.
33

44
export type ExperimentationPopulation = 'public' | 'insider' | 'internal' | 'team';
5-
export type AssignmentParameterSource = 'machineId' | 'extensionVersion' | 'language';
5+
export type AssignmentParameterSource = 'devDeviceId' | 'extensionVersion' | 'language';
6+
7+
export const EXPERIMENTATION_ASSIGNMENTS_ENDPOINT =
8+
'https://exp.individual.githubcopilot.com/api/v1/assignments';
9+
export const EXPERIMENTATION_IDENTITY_PARAMETER = 'devdeviceid';
610

711
export interface ExperimentationConfiguration {
812
readonly assignmentsEndpoint: string;
@@ -12,6 +16,7 @@ export interface ExperimentationConfiguration {
1216
}
1317

1418
const GENERIC_PARAMETERS = new Set([
19+
EXPERIMENTATION_IDENTITY_PARAMETER,
1520
'vscode_core_appversion',
1621
'vscode_core_build',
1722
'vscode_core_extensionname',
@@ -32,61 +37,43 @@ export function readExperimentationConfiguration(manifest: unknown): Experimenta
3237
if (!isRecord(config)) {
3338
throw new Error('The experimentation manifest entry must be an object.');
3439
}
35-
const endpoint = config.assignmentsEndpoint;
36-
if (typeof endpoint !== 'string') {
37-
throw new Error('Experimentation requires an approved assignmentsEndpoint.');
38-
}
39-
const url = new URL(endpoint);
40-
if (
41-
url.protocol !== 'https:' ||
42-
url.username ||
43-
url.password ||
44-
url.search ||
45-
url.hash ||
46-
!url.pathname.endsWith('/api/v1/assignments')
47-
) {
40+
if (config.assignmentsEndpoint !== undefined || config.identityParameter !== undefined) {
4841
throw new Error(
49-
'assignmentsEndpoint must be an HTTPS assignments API URL without credentials or query parameters.',
42+
'The experimentation endpoint and identity parameter are platform-owned and cannot be overridden.',
5043
);
5144
}
5245
const population = config.targetPopulation;
5346
if (population !== 'public' && population !== 'insider' && population !== 'internal' && population !== 'team') {
5447
throw new Error('Experimentation requires an explicitly approved targetPopulation.');
5548
}
56-
if (!isRecord(config.assignmentParameters)) {
49+
if (config.assignmentParameters !== undefined && !isRecord(config.assignmentParameters)) {
5750
throw new Error('Experimentation requires approved assignment parameter bindings.');
5851
}
5952

60-
const parameters: Record<string, AssignmentParameterSource> = {};
61-
for (const [name, source] of Object.entries(config.assignmentParameters)) {
53+
const parameters: Record<string, AssignmentParameterSource> = {
54+
[EXPERIMENTATION_IDENTITY_PARAMETER]: 'devDeviceId',
55+
};
56+
for (const [name, source] of Object.entries(config.assignmentParameters ?? {})) {
6257
if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(name) || /^x-/i.test(name) || GENERIC_PARAMETERS.has(name)) {
6358
throw new Error(
6459
'Assignment parameters must use new API names and must not replace generic SDK parameters.',
6560
);
6661
}
67-
if (source !== 'machineId' && source !== 'extensionVersion' && source !== 'language') {
62+
if (source !== 'extensionVersion' && source !== 'language') {
6863
throw new Error(
69-
'Unsupported assignment parameter source. Add an approved identity provider before enabling TAS.',
64+
'Unsupported assignment parameter source. The DevDeviceId binding is platform-owned.',
7065
);
7166
}
7267
parameters[name] = source;
7368
}
74-
const identityParameter = config.identityParameter;
75-
if (
76-
typeof identityParameter !== 'string' ||
77-
parameters[identityParameter] !== 'machineId' ||
78-
Object.values(parameters).filter((source) => source === 'machineId').length !== 1 ||
79-
Object.keys(parameters).length > 45
80-
) {
81-
throw new Error(
82-
'Experimentation requires one explicitly approved machineId identity binding and at most 45 parameters.',
83-
);
69+
if (Object.keys(parameters).length > 45) {
70+
throw new Error('Experimentation supports at most 45 assignment parameters.');
8471
}
8572

8673
return {
87-
assignmentsEndpoint: url.toString(),
74+
assignmentsEndpoint: EXPERIMENTATION_ASSIGNMENTS_ENDPOINT,
8875
targetPopulation: population,
89-
identityParameter,
76+
identityParameter: EXPERIMENTATION_IDENTITY_PARAMETER,
9077
assignmentParameters: Object.freeze(parameters),
9178
};
9279
}

‎src/common/experimentation/service.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type ExperimentationClientOptions = Omit<ExperimentationConfig, 'targetPo
4545
export type ExperimentationClientFactory = (
4646
options: ExperimentationClientOptions,
4747
) => ExperimentationClient | Promise<ExperimentationClient>;
48+
export type DevDeviceIdProvider = () => string | undefined;
4849

4950
export interface ExperimentationContext {
5051
readonly extension: { readonly packageJSON: unknown };
@@ -106,6 +107,7 @@ export class ExperimentationService implements Disposable {
106107
constructor(
107108
private readonly context: ExperimentationContext,
108109
private readonly createClient: ExperimentationClientFactory = createSdk,
110+
private readonly getDevDeviceId: DevDeviceIdProvider = () => undefined,
109111
disabledForTests = false,
110112
) {
111113
try {
@@ -241,12 +243,12 @@ export class ExperimentationService implements Disposable {
241243

242244
private async initialize(run: SdkRun): Promise<void> {
243245
const configuration = this.configuration!;
244-
const identity = getMachineId();
246+
const identity = this.getDevDeviceId();
245247
if (typeof identity !== 'string' || !identity.trim()) {
246-
throw new Error('The configured experimentation identity is unavailable.');
248+
throw new Error('The approved DevDeviceId experimentation identity is unavailable.');
247249
}
248250
const language = getLanguage();
249-
const values = { machineId: identity, extensionVersion: this.version, language };
251+
const values = { devDeviceId: identity, extensionVersion: this.version, language };
250252
const parameters = new Map<string, string>();
251253
for (const [name, source] of Object.entries(configuration.assignmentParameters)) {
252254
const value = values[source];
@@ -259,7 +261,7 @@ export class ExperimentationService implements Disposable {
259261
const sdkTargetingValues = {
260262
applicationVersion: trimVersionSuffix(getVSCodeVersion()),
261263
build: getAppName(),
262-
clientId: identity,
264+
clientId: getMachineId(),
263265
language,
264266
};
265267
const storage = new ExperimentationStorage(
@@ -422,14 +424,15 @@ let activeService: ExperimentationService | undefined;
422424
/** Register the activation's internal service without awaiting networking. */
423425
export function initializeExperimentation(
424426
context: ExperimentationContext & { subscriptions: Disposable[] },
427+
getDevDeviceId?: DevDeviceIdProvider,
425428
): ExperimentationService {
426429
if (activeService) {
427430
return activeService;
428431
}
429432
const testExecution = [
430433
'VSC_PYTHON_CI_TEST', 'VSC_PYTHON_INTEGRATION_TEST', 'VSC_PYTHON_SMOKE_TEST', 'VSC_PYTHON_E2E_TEST',
431434
].some((name) => !!process.env[name]);
432-
const service = new ExperimentationService(context, createSdk, testExecution);
435+
const service = new ExperimentationService(context, createSdk, getDevDeviceId, testExecution);
433436
activeService = service;
434437
context.subscriptions.push({
435438
dispose: () => {

0 commit comments

Comments
 (0)