Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ it('passes correct environmentMetadata to plugin getHooks and register functions

makeClient(
'client-side-id',
{ kind: 'user', key: '', anonymous: true },
{ kind: 'user', anonymous: true },
AutoEnvAttributes.Disabled,
{
streaming: false,
Expand Down Expand Up @@ -282,7 +282,7 @@ it('passes correct environmentMetadata without optional fields', async () => {

makeClient(
'client-side-id',
{ kind: 'user', key: '', anonymous: true },
{ kind: 'user', anonymous: true },
AutoEnvAttributes.Disabled,
{
streaming: false,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/browser/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
LDContext,
LDContextCommon,
LDContextMeta,
LDContextStrict,
LDEvaluationDetail,
LDEvaluationDetailTyped,
LDEvaluationReason,
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/browser/src/compat/LDClientCompatImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
cancelableTimedPromise,
Hook,
LDContext,
LDContextStrict,
LDEvaluationDetail,
LDEvaluationDetailTyped,
LDFlagSet,
Expand Down Expand Up @@ -184,7 +185,7 @@ export default class LDClientCompatImpl implements LDClient {
);
}

getContext(): LDContext | undefined {
getContext(): LDContextStrict | undefined {
return this._client.getContext();
}

Expand Down
44 changes: 41 additions & 3 deletions packages/shared/sdk-client/__tests__/LDClientImpl.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AutoEnvAttributes, clone, Hasher, LDContext, LDLogger } from '@launchdarkly/js-sdk-common';
import { AutoEnvAttributes, clone, Hasher, LDLogger } from '@launchdarkly/js-sdk-common';

import { LDContext } from '../src/api/LDContext';
import { DataSourceState } from '../src/datasource/DataSourceStatus';
import LDClientImpl from '../src/LDClientImpl';
import { Flags } from '../src/types';
Expand Down Expand Up @@ -235,7 +236,7 @@ describe('sdk-client object', () => {
defaultPutResponse['dev-test-flag'].value = false;
simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }];

const carContext: LDContext = { kind: 'car', anonymous: true, key: '' };
const carContext: LDContext = { kind: 'car', anonymous: true };

mockPlatform.crypto.randomUUID.mockReturnValue('random1');

Expand All @@ -253,9 +254,46 @@ describe('sdk-client object', () => {
});
});

test('identify multi kind context with anonymous', async () => {
defaultPutResponse['dev-test-flag'].value = false;
simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }];

const carContext: LDContext = {
kind: 'multi',
user: { anonymous: true },
org: { anonymous: true },
};

mockPlatform.crypto.randomUUID.mockReturnValue('random1');

await ldc.identify(carContext);
const c = ldc.getContext();
const all = ldc.allFlags();

expect(c).toEqual({
kind: 'multi',
user: { anonymous: true, key: 'random1' },
org: { anonymous: true, key: 'random1' },
...autoEnv,
});
expect(all).toMatchObject({
'dev-test-flag': false,
});
});

test('identify error invalid context', async () => {
const carContext: LDContext = { kind: 'car', key: '' };
const carContext = { kind: 'car' };

// @ts-expect-error - invalid context
await expect(ldc.identify(carContext)).rejects.toThrow(/no key/);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(ldc.getContext()).toBeUndefined();
});

test('identify error invalid multi kindcontext', async () => {
const carContext = { kind: 'multi', user: { name: 'test' } };

// @ts-ignore - invalid context
await expect(ldc.identify(carContext)).rejects.toThrow(/no key/);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(ldc.getContext()).toBeUndefined();
Expand Down
12 changes: 9 additions & 3 deletions packages/shared/sdk-client/__tests__/context/ensureKey.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type {
Crypto,
LDContext,
LDContextCommon,
LDMultiKindContext,
LDUser,
} from '@launchdarkly/js-sdk-common';

import { LDContext } from '../../src/api/LDContext';
import { ensureKey } from '../../src/context/ensureKey';
import { createBasicPlatform } from '../createBasicPlatform';

Expand Down Expand Up @@ -57,6 +57,12 @@ describe('ensureKey', () => {
});

test('ensureKey should create key for single anonymous context', async () => {
const context: LDContext = { kind: 'org', anonymous: true };
const c = await ensureKey(context, mockPlatform);
expect(c.key).toEqual('random1');
});

test('ensureKey should create key for single anonymous context with empty key string', async () => {
const context: LDContext = { kind: 'org', anonymous: true, key: '' };
const c = await ensureKey(context, mockPlatform);
expect(c.key).toEqual('random1');
Expand All @@ -65,7 +71,7 @@ describe('ensureKey', () => {
test('ensureKey should create key for an anonymous context in multi', async () => {
const context: LDContext = {
kind: 'multi',
user: { anonymous: true, key: '' },
Copy link
Member

Choose a reason for hiding this comment

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

We do probably want 1 test to make sure that an empty key also still works.

user: { anonymous: true },
org: { key: 'orgKey' },
};

Expand All @@ -78,7 +84,7 @@ describe('ensureKey', () => {
test('ensureKey should create key for all anonymous contexts in multi', async () => {
const context: LDContext = {
kind: 'multi',
user: { anonymous: true, key: '' },
user: { anonymous: true },
org: { anonymous: true, key: '' },
};

Expand Down
9 changes: 5 additions & 4 deletions packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
defaultHeaders,
internal,
LDClientError,
LDContext,
LDFlagSet,
LDFlagValue,
LDHeaders,
Expand All @@ -21,6 +20,8 @@ import {
Hook,
LDClient,
LDClientIdentifyResult,
LDContext,
LDContextStrict,
LDIdentifyError,
LDIdentifyResult,
LDIdentifyShed,
Expand Down Expand Up @@ -139,7 +140,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
this._diagnosticsManager,
);
this.emitter = new LDEmitter();
this.emitter.on('error', (c: LDContext, err: any) => {
this.emitter.on('error', (c: LDContextStrict, err: any) => {
this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`);
});

Expand Down Expand Up @@ -234,14 +235,14 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
return { result: true };
}

getContext(): LDContext | undefined {
getContext(): LDContextStrict | undefined {
// The LDContext returned here may have been modified by the SDK (for example: adding auto env attributes).
// We are returning an LDContext here to maintain a consistent representation of context to the consuming
// code. We are returned the unchecked context so that if a consumer identifies with an invalid context
// and then calls getContext, they get back the same context they provided, without any assertion about
// validity.
return this._activeContextTracker.hasContext()
? clone<LDContext>(this._activeContextTracker.getUnwrappedContext())
? clone<LDContextStrict>(this._activeContextTracker.getUnwrappedContext())
: undefined;
}

Expand Down
5 changes: 3 additions & 2 deletions packages/shared/sdk-client/src/api/LDClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { LDContext, LDFlagSet, LDFlagValue, LDLogger } from '@launchdarkly/js-sdk-common';
import { LDFlagSet, LDFlagValue, LDLogger } from '@launchdarkly/js-sdk-common';

import { Hook } from './integrations/Hooks';
import type { LDContext, LDContextStrict } from './LDContext';
import { LDEvaluationDetail, LDEvaluationDetailTyped } from './LDEvaluationDetail';
import { LDIdentifyOptions } from './LDIdentifyOptions';
import { LDIdentifyResult } from './LDIdentifyResult';
Expand Down Expand Up @@ -85,7 +86,7 @@ export interface LDClient {
* This is the context that was most recently passed to {@link identify}, or, if {@link identify} has never
* been called, this will be undefined.
*/
getContext(): LDContext | undefined;
getContext(): LDContextStrict | undefined;

/**
* Identifies a context to LaunchDarkly.
Expand Down
73 changes: 73 additions & 0 deletions packages/shared/sdk-client/src/api/LDContext.ts
Copy link
Member

Choose a reason for hiding this comment

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

I think this typing looks good for a multi-kind context, but not for a single kind context. In that this typing for a single kind would make key appear to be not an available option.

I wonder if we should just be using basically a copy of the single/multi context kinds with key correct specified as being optional. Then converting that for internal use?

Though, I suppose, it could be an Omit and then & { key?: string }. I want to make sure we have somewhat intelligible code completion though.

Copy link
Member

Choose a reason for hiding this comment

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

Or it could be type singleKindOptionalKey = Omit<LDSingleKindContextBase, 'key'> | LDSingleKindContextBase

Though again I am not sure how good the intellisense for that would be. Maybe fine.

Copy link
Contributor Author

@joker23 joker23 Jan 7, 2026

Choose a reason for hiding this comment

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

I think there is something fundamentally wrong here... so the ensureKey function will ONLY federate the context with a key if the context is anonymous:

So given that perhaps the correct thing to do is create an anonymous context interface, eg:

interface anonymousSingleKindContext extends Omit<LDSingleKindContextBase, 'key'> {
  key?: string;
  anonymous: boolean;
}

And do the same for multi of course

Then ultimately the LDContext that could be used in a client side identify will be:

type LDContextWithAnonymous =
  | LDSingleKindContext                         // original single kind context
  | anonymousSingleKindContext          // anon context that could be missing key
  | multiKindContextWithAnonymous;

@kinyoklion FYI

Copy link
Member

Choose a reason for hiding this comment

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

This seems like the right path.

We do need to also consider what the impact of this will be consumption side. My reflex is that it should be exported as LDContext, but if not, then we need to make sure that LDContext doesn't upset the structural typing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ended up going the route of introducing a new type since I think there is still some value in having a context type that can match a context that is "ensured". Typescript is a bit finicky.

Copy link
Member

Choose a reason for hiding this comment

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

This might be fine, but I think I wasn't super clear in articulating. We certainly want a non-optional type inside the boundaries of the SDK. But I am less certain we want such a type outside the boundaries of the SDK. People already have some difficulty with the types currently exposed.

Copy link
Member

Choose a reason for hiding this comment

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

I do think we need a test that forms all the context in the various permissions as a type of typescript test.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see... yea taking a step back and thinking about how we would want users to interact with this SDK, it is more helpful for them to see the context type with an optional key prop. I'll make those changes! Thanks!

Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { LDContextCommon, LDSingleKindContext, LDUser } from '@launchdarkly/js-sdk-common';

export { LDContext as LDContextStrict } from '@launchdarkly/js-sdk-common';

/**
* @see {@link LDSingleKindContext}
*
* The only difference is that the kind cannot be 'multi' which is reserved for multi-kind contexts.
* Expect this change to be propogated to the common package in the future.
*
* @privateRemarks
* This is helpful for type narrowing to avoid ambiguity when the kind is 'multi'.
*/
type strictSingleKindContext = Omit<LDSingleKindContext, 'kind' | 'key'> & {
key: string;
kind: Exclude<string, 'multi'>;
};

/**
* An anonymous version of {@link LDSingleKindContext}. This is a valid form for contexts used in a
* client-side sdk because the key will be generated if missing by the {@link ensureKey} function.
*/
type anonymousSingleKindContext = Omit<LDSingleKindContext, 'key' | 'anonymous' | 'kind'> & {
key?: string;
anonymous: true;
kind: Exclude<string, 'multi'>;
};

/**
* An anonymous version of {@link LDContextCommon}. This is a valid form for contexts used in a
* client-side sdk because the key will be generated if missing by the {@link ensureKey} function.
*/
type anonymousLDContextCommon = Omit<LDContextCommon, 'key' | 'anonymous'> & {
key?: string;
anonymous: true;
};

/**
* An anonymous version of {@link LDMultiKindContext}. This is a valid form for contexts used in a
* client-side sdk because the keys will be generated if missing by the {@link ensureKey} function.
*/
interface multiKindContextWithAnonymous {
kind: 'multi';
[kind: string]: 'multi' | anonymousLDContextCommon | LDContextCommon;
}

/**
* This is the client side version of the `LDContext` type (referred to as {@link LDContextStrict} in this module).
* The key reason for this distinction is that client side contexts can be anonymous.
* An anonymous context is a context that satisfies the following definition:
* ```typescript
* {
* key?: string;
* anonymous: true;
* }
* ```
* > NOTE: A context with the `anonymous` property set to `false` or is `undefined` **MUST** have a `key`
* > property set or it will be rejected by the SDK.
*
* Otherwise, refer to {@link LDContextStrict} for more details on how LaunchDarkly contexts work.
*
* @see {@link LDSingleKindContext}
* @see {@link LDMultiKindContext}
*
* @remarks
* Anonymous contexts are acceptable in the client side SDK because the SDK will generate a key for them if they are missing.
* The key generation logic is in the {@link ensureKey} function.
*/
export type LDContext =
| multiKindContextWithAnonymous
| strictSingleKindContext
| anonymousSingleKindContext
| LDUser;
1 change: 1 addition & 0 deletions packages/shared/sdk-client/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './LDInspection';
export * from './LDIdentifyResult';
export * from './LDPlugin';
export * from './LDWaitForInitialization';
export * from './LDContext';
9 changes: 6 additions & 3 deletions packages/shared/sdk-client/src/context/ensureKey.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {
clone,
internal,
LDContext,
LDContextCommon,
LDMultiKindContext,
LDSingleKindContext,
LDUser,
Platform,
} from '@launchdarkly/js-sdk-common';

import type { LDContext, LDContextStrict } from '../api/LDContext';
import { getOrGenerateKey } from '../storage/getOrGenerateKey';
import { namespaceForAnonymousGeneratedContextKey } from '../storage/namespaceUtils';

Expand Down Expand Up @@ -63,8 +63,11 @@ const ensureKeyLegacy = async (c: LDUser, platform: Platform) => {
* @param context
* @param platform
*/
export const ensureKey = async (context: LDContext, platform: Platform): Promise<LDContext> => {
const cloned = clone<LDContext>(context);
export const ensureKey = async (
context: LDContext,
platform: Platform,
): Promise<LDContextStrict> => {
const cloned = clone<LDContextStrict>(context);

if (isSingleKind(cloned)) {
await ensureKeySingle(cloned as LDSingleKindContext, platform);
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/sdk-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export type {
LDWaitForInitializationComplete,
LDWaitForInitializationFailed,
LDWaitForInitializationTimeout,
LDContext,
LDContextStrict,
} from './api';

export type { DataManager, DataManagerFactory, ConnectionParams } from './DataManager';
Expand Down